Skip to content
Open
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
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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,27 @@

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.tenant.InMemoryTenantRepository;
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 net.onelitefeather.apus.operator.api.Tenant;
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 final InMemoryTenantRepository tenantRepository = new InMemoryTenantRepository();
private final BlueMapRenderController controller = new BlueMapRenderController(
repository, new PrincipalResolver(), new TenantResolver(), tenantRepository);

private static Authentication viewer(String tenant) {
return Authentication.build("carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant));
Expand All @@ -45,6 +49,25 @@ private static Authentication noRoles(String tenant) {
return Authentication.build("service-token", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, tenant));
}

private static Authentication platformAdmin() {
return Authentication.build("root", List.of("platform-admin"), Map.of());
}

private static Authentication owner(String tenant) {
return Authentication.build("alice", List.of("tenant-owner"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant));
}

private static Authentication operator(String tenant) {
return Authentication.build("bob", List.of("tenant-operator"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant));
}

private static Tenant tenant(String name, String namespace) {
Tenant tenant = new Tenant();
tenant.getMetadata().setName(name);
tenant.getStatus().setNamespace(namespace);
return tenant;
}

private static BlueMapRender render(String name, String mapName) {
BlueMapRender render = new BlueMapRender();
render.getMetadata().setName(name);
Expand Down Expand Up @@ -93,4 +116,44 @@ void getByIdRejectsACallerWithNoTenantRole() {
repository.put("bluemap-acme", render("render-1", "survival-overworld"));
assertThrows(ForbiddenException.class, () -> controller.getById(noRoles("acme"), "render-1"));
}

@Test
void listClusterReturnsRendersAcrossEveryTenantForAPlatformAdmin() {
tenantRepository.put(tenant("acme", "bluemap-acme"));
tenantRepository.put(tenant("globex", "bluemap-globex"));
repository.put("bluemap-acme", render("render-1", "survival-overworld"));
repository.put("bluemap-globex", render("render-2", "creative-overworld"));

var response = controller.listCluster(platformAdmin());

assertEquals(200, response.getStatus().getCode());
assertEquals(2, response.body().size());
assertTrue(response.body().stream()
.anyMatch(entry -> entry.tenant().equals("acme") && entry.render().name().equals("render-1")));
assertTrue(response.body().stream()
.anyMatch(entry -> entry.tenant().equals("globex") && entry.render().name().equals("render-2")));
}

@Test
void listClusterSkipsATenantWithNoNamespaceInStatusYet() {
tenantRepository.put(tenant("brandNew", null));

var response = controller.listCluster(platformAdmin());

assertEquals(0, response.body().size());
}

/**
* The security-critical case (task brief C2): every role other than {@code platform-admin}
* must be rejected, not just "a caller with no roles" -- including the tenant-level roles
* that *do* pass {@code /api/renders}' own gate, since this is the one endpoint on this
* controller that would otherwise leak every tenant's renders to any authenticated caller.
*/
@Test
void listClusterRejectsEveryNonPlatformAdminRole() {
assertThrows(ForbiddenException.class, () -> controller.listCluster(owner("acme")));
assertThrows(ForbiddenException.class, () -> controller.listCluster(operator("acme")));
assertThrows(ForbiddenException.class, () -> controller.listCluster(viewer("acme")));
assertThrows(ForbiddenException.class, () -> controller.listCluster(noRoles("acme")));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@
* 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.
*
* <p>Public (not package-private): {@code BlueMapRenderControllerTest} (in the sibling {@code
* rest.render} test package) also needs a {@code TenantRepository} fake for {@code
* GET /api/renders/cluster}'s tests, and this is the one already exercised by {@code
* TenantControllerTest} -- reusing it keeps there from being two divergent in-memory fakes for
* the same interface.
*/
final class InMemoryTenantRepository implements TenantRepository {
public final class InMemoryTenantRepository implements TenantRepository {

private final Map<String, Tenant> byName = new LinkedHashMap<>();

void put(Tenant tenant) {
public void put(Tenant tenant) {
byName.put(tenant.getMetadata().getName(), tenant);
}

Expand All @@ -52,4 +58,10 @@ public Tenant create(Tenant tenant) {
put(tenant);
return tenant;
}

@Override
public Tenant update(Tenant tenant) {
put(tenant);
return tenant;
}
}
Loading