diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/Directory.java b/api/src/main/java/net/onelitefeather/apus/api/directory/Directory.java
new file mode 100644
index 0000000..f15a55a
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/Directory.java
@@ -0,0 +1,75 @@
+/**
+ * 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.directory;
+
+import java.util.List;
+
+/**
+ * What Apus needs from the identity provider, and nothing more.
+ *
+ *
An interface rather than a Graph client used directly, for two reasons that both bite in
+ * practice. Every controller test would otherwise need a Graph credential and a live tenant, so
+ * the interesting cases -- a tenant-owner reaching into another tenant, a password reset aimed at
+ * an administrator -- would be the ones nobody could write a test for. And the operations here
+ * are the complete list of what the granted permissions are used for: a reader checking whether
+ * {@code Group.ReadWrite.All} is being used responsibly reads this file, not a client full of
+ * URLs.
+ *
+ *
Every method may throw {@link DirectoryUnavailableException}. Graph is somebody else's
+ * service and it will be down or throttling at some point; callers are expected to degrade that
+ * panel rather than fail the page around it.
+ *
+ *
Nothing here checks authorisation. That is {@link DirectoryGuard}'s job, called by
+ * the controller before it gets this far. An implementation that also checked would invite the
+ * belief that either check alone is enough.
+ */
+public interface Directory {
+
+ /** The teams (nested groups) belonging to a tenant's group. */
+ List teamsIn(String groupId);
+
+ /** The members of a tenant's group, each carrying whatever privileged roles they hold. */
+ List membersOf(String groupId);
+
+ /**
+ * Creates a team inside a tenant's group and returns it.
+ *
+ * @param groupId the tenant's group, already checked by {@link DirectoryGuard}
+ * @param displayName what to call the new team
+ */
+ DirectoryTeam createTeam(String groupId, String displayName);
+
+ /**
+ * Invites somebody by e-mail and adds them to the tenant's group.
+ *
+ * @return the invited user, as the directory now knows them
+ */
+ DirectoryUser invite(String groupId, String email, String displayName);
+
+ /** One user by id, or {@code null} if the directory has no such account. */
+ DirectoryUser findUser(String userId);
+
+ /**
+ * Resets a password and returns the temporary one.
+ *
+ *
The returned value is shown to a human exactly once and never stored. It must not be
+ * logged, put in a span attribute, or written to any resource's status -- the same rule the
+ * tenant push token already follows.
+ */
+ String resetPassword(String userId);
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryFactory.java b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryFactory.java
new file mode 100644
index 0000000..137fd1e
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryFactory.java
@@ -0,0 +1,98 @@
+/**
+ * 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.directory;
+
+import io.micronaut.context.annotation.Factory;
+import jakarta.inject.Singleton;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Chooses the {@link Directory} the rest of the module gets: the real one when a credential is
+ * configured, and one that refuses everything when it is not.
+ *
+ *
Refusing is deliberately not the same as failing to start. Directory management is an
+ * optional capability behind a permission grant a platform may reasonably decline to make, and an
+ * API that would not boot without it would make that grant mandatory in practice. It is also not
+ * the same as pretending the directory is empty: an empty team list means "this tenant has no
+ * teams", which is a fact somebody would act on, whereas {@link DirectoryUnavailableException}
+ * makes the console show the panel as unavailable and leave the rest of the page alone.
+ */
+@Factory
+public class DirectoryFactory {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(DirectoryFactory.class);
+
+ @Singleton
+ public Directory directory(GraphDirectoryConfiguration config) {
+ if (!config.isConfigured()) {
+ LOGGER.info(
+ "no directory credential configured (apus.directory.*): teams, invitations and password"
+ + " resets are unavailable, everything else is unaffected");
+ return new UnconfiguredDirectory();
+ }
+ LOGGER.info(
+ "directory management enabled against {} as client {}",
+ config.getGraphEndpoint(),
+ config.getClientId());
+ return new GraphDirectory(config);
+ }
+
+ /**
+ * The directory when nobody has granted Apus access to one. Every method says the same thing,
+ * in words an administrator can act on -- naming the configuration that is missing rather
+ * than reporting a bare failure.
+ */
+ static final class UnconfiguredDirectory implements Directory {
+
+ private static final String MESSAGE =
+ "directory management is not configured on this platform (apus.directory.tenant-id,"
+ + " .client-id and .client-secret)";
+
+ @Override
+ public List teamsIn(String groupId) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+
+ @Override
+ public List membersOf(String groupId) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+
+ @Override
+ public DirectoryTeam createTeam(String groupId, String displayName) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+
+ @Override
+ public DirectoryUser invite(String groupId, String email, String displayName) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+
+ @Override
+ public DirectoryUser findUser(String userId) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+
+ @Override
+ public String resetPassword(String userId) {
+ throw new DirectoryUnavailableException(MESSAGE);
+ }
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryGuard.java b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryGuard.java
new file mode 100644
index 0000000..1420aeb
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryGuard.java
@@ -0,0 +1,160 @@
+/**
+ * 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.directory;
+
+import jakarta.inject.Singleton;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import net.onelitefeather.apus.api.security.ApusPrincipal;
+import net.onelitefeather.apus.api.security.ForbiddenException;
+import net.onelitefeather.apus.api.security.Role;
+
+/**
+ * The narrowing that Microsoft Graph itself does not offer.
+ *
+ *
The permissions behind the directory operations -- {@code Group.ReadWrite.All}, {@code
+ * User.ReadWrite.All}, {@code User.Invite.All} -- are directory-wide. Entra has no "these groups
+ * only" variant of any of them, so the credential this API holds could rename any group in the
+ * organisation and reset the password of any account in it, including accounts that have nothing
+ * to do with Apus. Nothing outside this class limits that.
+ *
+ *
So the limit lives here, as a guard the operations call rather than a rule each of
+ * them is expected to remember, and it is closed by default: an instance that has not been told
+ * which groups are managed refuses everything. That is deliberately also its state in the first
+ * moments after startup and its state if the tenant index ever fails to load -- failing shut is
+ * the only acceptable direction for a decision of this weight.
+ *
+ *
Pure: no network call, no repository lookup. A guard that has to reach out to decide is a
+ * guard that fails open when the network does, which is exactly when it matters most. Everything
+ * it needs is either handed to it ({@link DirectoryUser#privilegedRoles()}) or set once by the
+ * index that watches {@code Tenant} resources.
+ */
+@Singleton
+public class DirectoryGuard {
+
+ /**
+ * Directory roles that grant power over the directory itself. Someone holding any of these
+ * must never have their password reset through Apus: a tenant-owner who could do that would
+ * be one console button away from owning the whole organisation.
+ *
+ *
Compared case-insensitively, and the list is deliberately generous -- a role that turns
+ * out not to be dangerous costs an administrator one manual password reset in Entra, whereas
+ * one missing from the list costs the directory.
+ */
+ private static final Set PRIVILEGED_ROLES = Set.of(
+ "global administrator",
+ "company administrator",
+ "privileged role administrator",
+ "privileged authentication administrator",
+ "user administrator",
+ "authentication administrator",
+ "helpdesk administrator",
+ "password administrator",
+ "security administrator",
+ "conditional access administrator",
+ "application administrator",
+ "cloud application administrator",
+ "directory writers",
+ "partner tier1 support",
+ "partner tier2 support");
+
+ /**
+ * The groups some {@code Tenant} currently claims via {@code spec.identity.groupId}. Held in
+ * an {@link AtomicReference} because it is replaced wholesale by the tenant index on a
+ * watch event while requests are reading it; an immutable set swapped atomically means a
+ * reader either sees the whole old set or the whole new one, never a half-updated one.
+ */
+ private final AtomicReference> managedGroups = new AtomicReference<>(Set.of());
+
+ /** Replaces the managed-group set. Called by the tenant index, not by request handling. */
+ public void setManagedGroups(Set groups) {
+ managedGroups.set(groups == null ? Set.of() : Set.copyOf(groups));
+ }
+
+ /** The groups currently considered Apus's to touch. */
+ public Set managedGroups() {
+ return managedGroups.get();
+ }
+
+ /**
+ * Refuses any group that no {@code Tenant} claims -- including a blank or absent one, since
+ * an unconfigured tenant must not end up the widest on the platform rather than the
+ * narrowest.
+ */
+ public void requireManagedGroup(ApusPrincipal principal, String groupId) {
+ Objects.requireNonNull(principal, "principal must not be null");
+ if (groupId == null || groupId.isBlank()) {
+ throw new ForbiddenException("this tenant has no identity group configured");
+ }
+ if (!managedGroups.get().contains(groupId)) {
+ throw new ForbiddenException("group '" + groupId + "' is not managed by Apus");
+ }
+ }
+
+ /**
+ * Read access to one tenant's directory: the group must be managed, and the caller must be a
+ * platform admin or a member of that very tenant.
+ */
+ public void requireTenantAccess(ApusPrincipal principal, String tenant, String groupId) {
+ requireManagedGroup(principal, groupId);
+ if (principal.isPlatformAdmin()) {
+ return;
+ }
+ if (principal.tenant() == null || !principal.tenant().equals(tenant)) {
+ throw new ForbiddenException("not a member of tenant '" + tenant + "'");
+ }
+ }
+
+ /**
+ * Write access to one tenant's directory. Everything {@link #requireTenantAccess} demands,
+ * plus a role that may actually change things -- a viewer reads, and these operations do not
+ * read.
+ */
+ public void requireTenantWrite(ApusPrincipal principal, String tenant, String groupId) {
+ requireTenantAccess(principal, tenant, groupId);
+ if (principal.isPlatformAdmin()) {
+ return;
+ }
+ if (!principal.roles().contains(Role.TENANT_OWNER)) {
+ throw new ForbiddenException("changing a tenant's directory requires the tenant-owner role");
+ }
+ }
+
+ /**
+ * The last check before a password is reset, and the one worth reading twice.
+ *
+ *
Refuses a target holding any {@link #PRIVILEGED_ROLES} role, because a tenant-owner able
+ * to reset a directory administrator's password owns the organisation. Refuses the caller's
+ * own account too -- a self-service password change belongs at the identity provider, where
+ * it is challenged; this permission exists for helping somebody else.
+ */
+ public void requirePasswordResetAllowed(ApusPrincipal principal, DirectoryUser target) {
+ Objects.requireNonNull(target, "target must not be null");
+ if (target.privilegedRoles().stream()
+ .map(role -> role.toLowerCase(Locale.ROOT))
+ .anyMatch(PRIVILEGED_ROLES::contains)) {
+ throw new ForbiddenException(
+ "refusing to reset the password of a privileged directory account (" + target.id() + ")");
+ }
+ if (target.id().equals(principal.subject())) {
+ throw new ForbiddenException("reset your own password at the identity provider, not here");
+ }
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryTeam.java b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryTeam.java
new file mode 100644
index 0000000..3d450dd
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryTeam.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.directory;
+
+import java.util.Objects;
+
+/**
+ * A team within a tenant: a group nested inside the tenant's own group.
+ *
+ * @param id the directory's object id
+ * @param displayName what to show a human
+ * @param memberCount how many people are in it, or {@code -1} when the directory could be asked
+ * for the team but not for its size. Deliberately not {@code 0}: a zero that means "we could
+ * not count" is a lie an administrator would act on
+ */
+public record DirectoryTeam(String id, String displayName, int memberCount) {
+
+ /** Sentinel for {@link #memberCount} when the count could not be obtained. */
+ public static final int COUNT_UNAVAILABLE = -1;
+
+ public DirectoryTeam {
+ Objects.requireNonNull(id, "id must not be null");
+ }
+
+ /** Whether {@link #memberCount} is a real count rather than {@link #COUNT_UNAVAILABLE}. */
+ public boolean hasMemberCount() {
+ return memberCount >= 0;
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUnavailableException.java b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUnavailableException.java
new file mode 100644
index 0000000..49a3864
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUnavailableException.java
@@ -0,0 +1,42 @@
+/**
+ * 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.directory;
+
+/**
+ * The identity provider could not be reached, or refused to answer.
+ *
+ *
Its own type rather than a generic failure because callers treat it differently on purpose:
+ * a tenant whose storage and renders are perfectly fine must not become unreadable because
+ * Microsoft is throttling. Panels that depend on the directory report themselves unavailable; the
+ * page around them keeps working.
+ *
+ *
Never confused with "no such user" or "not permitted" -- those are a {@code null} return and
+ * a {@link net.onelitefeather.apus.api.security.ForbiddenException} respectively. Collapsing them
+ * would let an outage read as an empty directory, and an empty directory is something an
+ * administrator would act on.
+ */
+public class DirectoryUnavailableException extends RuntimeException {
+
+ public DirectoryUnavailableException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public DirectoryUnavailableException(String message) {
+ super(message);
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUser.java b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUser.java
new file mode 100644
index 0000000..a713185
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUser.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.directory;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A person, as the directory reports them. Deliberately small: an identifier to act on, enough to
+ * show a human who this is, and the one fact the guard needs to make a decision.
+ *
+ * @param id the directory's own object id, the only value any operation is addressed by
+ * @param displayName what to show a human; may be blank in a directory that has none
+ * @param email the sign-in address, also blank-able -- an invited user has one before they have
+ * anything else
+ * @param privilegedRoles the directory roles this user holds that grant power over the directory
+ * itself (Global Administrator and friends). Empty for almost everyone. Carried on the user
+ * rather than looked up at decision time so {@link DirectoryGuard} can stay a pure function
+ * -- a guard that has to make a network call to decide is a guard that fails open when the
+ * network does
+ */
+public record DirectoryUser(String id, String displayName, String email, Set privilegedRoles) {
+
+ public DirectoryUser {
+ Objects.requireNonNull(id, "id must not be null");
+ privilegedRoles = privilegedRoles == null ? Set.of() : Set.copyOf(privilegedRoles);
+ }
+
+ /** Convenience for the common case: an ordinary member with no directory power at all. */
+ public static DirectoryUser member(String id, String displayName, String email) {
+ return new DirectoryUser(id, displayName, email, Set.of());
+ }
+
+ /** Whether this user holds any role that grants power over the directory itself. */
+ public boolean isPrivileged() {
+ return !privilegedRoles.isEmpty();
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectory.java b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectory.java
new file mode 100644
index 0000000..0655557
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectory.java
@@ -0,0 +1,355 @@
+/**
+ * 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.directory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link Directory} backed by Microsoft Graph, running as the confidential app registration
+ * described in {@link GraphDirectoryConfiguration}.
+ *
+ *
Plain {@code java.net.http} rather than the Graph SDK: the six operations in {@link
+ * Directory} are the complete extent of what this platform does to the directory, and a reader
+ * auditing a directory-wide permission grant should be able to see every request that grant
+ * enables without following an SDK's abstractions. The token is fetched with the
+ * client-credentials flow and cached until shortly before it expires.
+ *
+ *
Authorisation is not this class's job. {@link DirectoryGuard} decides, and the
+ * controller calls it before anything here runs. Repeating the checks here would invite the
+ * belief that either place alone is sufficient.
+ */
+public class GraphDirectory implements Directory {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(GraphDirectory.class);
+
+ /** Refresh this far before the token actually expires, so a call never races the boundary. */
+ private static final Duration TOKEN_EARLY_REFRESH = Duration.ofMinutes(5);
+
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);
+
+ private final GraphDirectoryConfiguration config;
+ private final HttpClient http;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final AtomicReference token = new AtomicReference<>();
+ private final SecureRandom random = new SecureRandom();
+
+ public GraphDirectory(GraphDirectoryConfiguration config) {
+ this.config = config;
+ this.http = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+ }
+
+ @Override
+ public List teamsIn(String groupId) {
+ JsonNode body = get("/groups/" + encode(groupId) + "/members/microsoft.graph.group"
+ + "?$select=id,displayName&$count=true");
+ List teams = new ArrayList<>();
+ for (JsonNode node : GraphResponses.items(body)) {
+ teams.add(GraphResponses.team(node));
+ }
+ return teams;
+ }
+
+ @Override
+ public List membersOf(String groupId) {
+ JsonNode body = get("/groups/" + encode(groupId) + "/members/microsoft.graph.user"
+ + "?$select=id,displayName,mail,userPrincipalName");
+ List users = new ArrayList<>();
+ for (JsonNode node : GraphResponses.items(body)) {
+ // Roles are not fetched per member here: one extra request per user would turn a
+ // member list into a burst that Graph throttles. The list is a display; the guard
+ // fetches roles for the one user an operation actually targets, in findUser.
+ users.add(GraphResponses.user(node, Set.of()));
+ }
+ return users;
+ }
+
+ @Override
+ public DirectoryTeam createTeam(String groupId, String displayName) {
+ String nickname = mailNickname(displayName);
+ String payload =
+ """
+ {"displayName":%s,"mailNickname":%s,"mailEnabled":false,"securityEnabled":true}"""
+ .formatted(quote(displayName), quote(nickname));
+ JsonNode created = post("/groups", payload);
+ String teamId = GraphResponses.text(created, "id");
+ if (teamId.isBlank()) {
+ throw new DirectoryUnavailableException("the directory created a group but returned no id");
+ }
+ // Nesting it under the tenant's group is what makes it *this tenant's* team rather than
+ // a loose group in the directory -- and it is what every later read filters by.
+ post(
+ "/groups/" + encode(groupId) + "/members/$ref",
+ """
+ {"@odata.id":%s}"""
+ .formatted(quote(config.getGraphEndpoint() + "/directoryObjects/" + teamId)));
+ return new DirectoryTeam(teamId, displayName, 0);
+ }
+
+ @Override
+ public DirectoryUser invite(String groupId, String email, String displayName) {
+ String payload =
+ """
+ {"invitedUserEmailAddress":%s,"invitedUserDisplayName":%s,\
+ "inviteRedirectUrl":%s,"sendInvitationMessage":true}"""
+ .formatted(quote(email), quote(displayName), quote("https://myapps.microsoft.com"));
+ JsonNode invitation = post("/invitations", payload);
+ JsonNode invited = invitation.get("invitedUser");
+ String userId = GraphResponses.text(invited, "id");
+ if (userId.isBlank()) {
+ throw new DirectoryUnavailableException("the directory accepted the invitation but returned no user");
+ }
+ post(
+ "/groups/" + encode(groupId) + "/members/$ref",
+ """
+ {"@odata.id":%s}"""
+ .formatted(quote(config.getGraphEndpoint() + "/directoryObjects/" + userId)));
+ return DirectoryUser.member(userId, displayName, email);
+ }
+
+ @Override
+ public DirectoryUser findUser(String userId) {
+ JsonNode body = get("/users/" + encode(userId) + "?$select=id,displayName,mail,userPrincipalName");
+ if (body == null || GraphResponses.text(body, "id").isBlank()) {
+ return null;
+ }
+ // Roles are fetched here and only here: this is the user an operation is about to act on,
+ // and DirectoryGuard cannot decide about a password reset without knowing them.
+ JsonNode roles = get("/users/" + encode(userId) + "/transitiveMemberOf?$select=id,displayName");
+ return GraphResponses.user(body, GraphResponses.directoryRoles(roles));
+ }
+
+ @Override
+ public String resetPassword(String userId) {
+ String temporary = temporaryPassword();
+ patch(
+ "/users/" + encode(userId),
+ """
+ {"passwordProfile":{"password":%s,"forceChangePasswordNextSignIn":true}}"""
+ .formatted(quote(temporary)));
+ // The password itself never reaches this log line, a span attribute or any resource
+ // status -- the same rule the tenant push token follows.
+ LOGGER.info("reset the password of directory user '{}'", userId);
+ return temporary;
+ }
+
+ /**
+ * A temporary password the user must replace at next sign-in. Base64 of 24 random bytes from
+ * {@link SecureRandom}: comfortably past any complexity policy, and not something anyone is
+ * expected to remember -- it is shown once and typed once.
+ */
+ private String temporaryPassword() {
+ byte[] bytes = new byte[24];
+ random.nextBytes(bytes);
+ return "Ap!" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ /**
+ * A mail nickname Graph will accept: it rejects spaces and most punctuation, and a team named
+ * "Map Builders" is otherwise a 400 rather than a group.
+ */
+ private static String mailNickname(String displayName) {
+ String cleaned = displayName.replaceAll("[^A-Za-z0-9]", "").toLowerCase(java.util.Locale.ROOT);
+ return cleaned.isBlank() ? "team" : cleaned;
+ }
+
+ // --- transport ------------------------------------------------------------------------------
+
+ private JsonNode get(String path) {
+ return send(HttpRequest.newBuilder(URI.create(config.getGraphEndpoint() + path))
+ .header("Authorization", "Bearer " + accessToken())
+ .header("ConsistencyLevel", "eventual")
+ .timeout(REQUEST_TIMEOUT)
+ .GET());
+ }
+
+ private JsonNode post(String path, String body) {
+ return send(HttpRequest.newBuilder(URI.create(config.getGraphEndpoint() + path))
+ .header("Authorization", "Bearer " + accessToken())
+ .header("Content-Type", "application/json")
+ .timeout(REQUEST_TIMEOUT)
+ .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)));
+ }
+
+ private JsonNode patch(String path, String body) {
+ return send(HttpRequest.newBuilder(URI.create(config.getGraphEndpoint() + path))
+ .header("Authorization", "Bearer " + accessToken())
+ .header("Content-Type", "application/json")
+ .timeout(REQUEST_TIMEOUT)
+ .method("PATCH", HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Sends a request and turns anything other than success into {@link
+ * DirectoryUnavailableException}. A {@code 429} is retried once after the {@code Retry-After}
+ * the service asked for, because Graph throttles routinely and a single retry converts most
+ * of it into a slightly slower page rather than an error.
+ */
+ private JsonNode send(HttpRequest.Builder builder) {
+ HttpResponse response = exchange(builder);
+ if (response.statusCode() == 429) {
+ long wait = response.headers()
+ .firstValue("Retry-After")
+ .map(value -> {
+ try {
+ return Long.parseLong(value.trim());
+ } catch (NumberFormatException e) {
+ return 2L;
+ }
+ })
+ .orElse(2L);
+ LOGGER.warn("the directory is throttling; retrying once in {}s", wait);
+ try {
+ Thread.sleep(Math.min(wait, 10) * 1000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DirectoryUnavailableException("interrupted while waiting out directory throttling", e);
+ }
+ response = exchange(builder);
+ }
+ if (response.statusCode() == 404) {
+ return null;
+ }
+ if (response.statusCode() >= 300) {
+ // The body can carry the caller's own data back; only the status and Graph's own
+ // error code go into the message.
+ throw new DirectoryUnavailableException(
+ "the directory answered " + response.statusCode() + " (" + graphErrorCode(response.body()) + ")");
+ }
+ if (response.body() == null || response.body().isBlank()) {
+ return mapper.createObjectNode();
+ }
+ try {
+ return mapper.readTree(response.body());
+ } catch (Exception e) {
+ throw new DirectoryUnavailableException("the directory answered with something that is not JSON", e);
+ }
+ }
+
+ private HttpResponse exchange(HttpRequest.Builder builder) {
+ try {
+ return http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DirectoryUnavailableException("interrupted while talking to the directory", e);
+ } catch (Exception e) {
+ throw new DirectoryUnavailableException("could not reach the directory", e);
+ }
+ }
+
+ /** Graph's own machine-readable error code, for a message that says something useful. */
+ private String graphErrorCode(String body) {
+ if (body == null || body.isBlank()) {
+ return "no detail";
+ }
+ try {
+ JsonNode error = mapper.readTree(body).get("error");
+ String code = GraphResponses.text(error, "code");
+ return code.isBlank() ? "no detail" : code;
+ } catch (Exception e) {
+ return "no detail";
+ }
+ }
+
+ /** The app-only access token, fetched on first use and reused until close to its expiry. */
+ private String accessToken() {
+ CachedToken cached = token.get();
+ if (cached != null && cached.isUsable()) {
+ return cached.value();
+ }
+ String form = "client_id=" + encode(config.getClientId())
+ + "&client_secret=" + encode(config.getClientSecret())
+ + "&scope=" + encode("https://graph.microsoft.com/.default")
+ + "&grant_type=client_credentials";
+ HttpRequest request = HttpRequest.newBuilder(
+ URI.create(config.getAuthority() + "/" + config.getTenantId() + "/oauth2/v2.0/token"))
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .timeout(REQUEST_TIMEOUT)
+ .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
+ .build();
+
+ HttpResponse response;
+ try {
+ response = http.send(request, HttpResponse.BodyHandlers.ofString());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DirectoryUnavailableException("interrupted while acquiring a directory token", e);
+ } catch (Exception e) {
+ throw new DirectoryUnavailableException("could not reach the identity provider for a token", e);
+ }
+ if (response.statusCode() >= 300) {
+ // Deliberately no body: a failed token response can echo the client_secret back.
+ throw new DirectoryUnavailableException(
+ "the identity provider refused the directory credential (" + response.statusCode() + ")");
+ }
+ try {
+ JsonNode body = mapper.readTree(response.body());
+ String value = GraphResponses.text(body, "access_token");
+ if (value.isBlank()) {
+ throw new DirectoryUnavailableException("the identity provider returned no access token");
+ }
+ JsonNode expires = body.get("expires_in");
+ long seconds = expires != null && expires.isNumber() ? expires.asLong() : 3600L;
+ CachedToken fresh = new CachedToken(value, Instant.now().plusSeconds(seconds));
+ token.set(fresh);
+ return value;
+ } catch (DirectoryUnavailableException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new DirectoryUnavailableException("could not read the identity provider's token response", e);
+ }
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ /** JSON string literal, so a display name containing a quote cannot break the request. */
+ private String quote(String value) {
+ try {
+ return mapper.writeValueAsString(value == null ? "" : value);
+ } catch (Exception e) {
+ throw new DirectoryUnavailableException("could not encode a value for the directory", e);
+ }
+ }
+
+ private record CachedToken(String value, Instant expiresAt) {
+ boolean isUsable() {
+ return Instant.now().isBefore(expiresAt.minus(TOKEN_EARLY_REFRESH));
+ }
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectoryConfiguration.java b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectoryConfiguration.java
new file mode 100644
index 0000000..163405f
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectoryConfiguration.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.directory;
+
+import io.micronaut.context.annotation.ConfigurationProperties;
+
+/**
+ * Credentials for the confidential app registration the directory operations run as.
+ *
+ *
Not the app registration the browser uses. That one is a SPA -- a public client,
+ * which cannot hold a secret and cannot use the client-credentials flow at all, so Graph
+ * application permissions granted there would be unusable and would suggest the browser held
+ * them. This is a second, confidential registration used only by this module, server-side.
+ *
+ *
{@link #getClientSecret()} arrives from a Kubernetes {@code Secret} via {@code secretKeyRef}
+ * and is never inlined into a manifest. A client secret rather than workload identity federation
+ * because federation is not available here: this cluster's OIDC issuer is an internal address
+ * Entra cannot reach to fetch signing keys.
+ *
+ *
All three values are empty by default, which switches the directory off entirely -- see
+ * {@link DirectoryFactory}. A platform that has not granted the permissions gets an API that
+ * says so, rather than one that fails to start.
+ */
+@ConfigurationProperties("apus.directory")
+public class GraphDirectoryConfiguration {
+
+ private String tenantId = "";
+ private String clientId = "";
+ private String clientSecret = "";
+ private String authority = "https://login.microsoftonline.com";
+ private String graphEndpoint = "https://graph.microsoft.com/v1.0";
+
+ /** Whether enough is configured to talk to the directory at all. */
+ public boolean isConfigured() {
+ return !tenantId.isBlank() && !clientId.isBlank() && !clientSecret.isBlank();
+ }
+
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ public void setTenantId(String tenantId) {
+ this.tenantId = tenantId == null ? "" : tenantId.trim();
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId == null ? "" : clientId.trim();
+ }
+
+ public String getClientSecret() {
+ return clientSecret;
+ }
+
+ public void setClientSecret(String clientSecret) {
+ this.clientSecret = clientSecret == null ? "" : clientSecret.trim();
+ }
+
+ public String getAuthority() {
+ return authority;
+ }
+
+ public void setAuthority(String authority) {
+ this.authority = authority == null || authority.isBlank() ? "https://login.microsoftonline.com" : authority;
+ }
+
+ public String getGraphEndpoint() {
+ return graphEndpoint;
+ }
+
+ public void setGraphEndpoint(String graphEndpoint) {
+ this.graphEndpoint =
+ graphEndpoint == null || graphEndpoint.isBlank() ? "https://graph.microsoft.com/v1.0" : graphEndpoint;
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/GraphResponses.java b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphResponses.java
new file mode 100644
index 0000000..b9b36f3
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/GraphResponses.java
@@ -0,0 +1,108 @@
+/**
+ * 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.directory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Turns Microsoft Graph's JSON into this module's own types.
+ *
+ *
Split out of {@code GraphDirectory} so it can be tested without a credential, a network or a
+ * live directory. The parsing is where the interesting mistakes live -- a group whose members
+ * come back under {@code value}, a user whose {@code mail} is null but whose
+ * {@code userPrincipalName} is not, a role assignment shaped differently from what the docs
+ * suggest -- and none of those are things worth discovering in production.
+ *
+ *
Every method tolerates a missing or null field rather than throwing. Graph omits what it has
+ * nothing to say about, and a directory listing that fails wholesale because one account has no
+ * display name would be worse than one that shows a blank.
+ */
+final class GraphResponses {
+
+ private GraphResponses() {}
+
+ /** Reads a {@code value} array, tolerating its absence. */
+ static List items(JsonNode body) {
+ List out = new ArrayList<>();
+ if (body == null) {
+ return out;
+ }
+ JsonNode value = body.get("value");
+ if (value != null && value.isArray()) {
+ value.forEach(out::add);
+ }
+ return out;
+ }
+
+ /**
+ * One group as a team. The member count is left {@link DirectoryTeam#COUNT_UNAVAILABLE}
+ * unless Graph actually returned one -- a zero meaning "not asked" is a lie somebody would
+ * act on.
+ */
+ static DirectoryTeam team(JsonNode node) {
+ String id = text(node, "id");
+ String name = text(node, "displayName");
+ JsonNode count = node.get("members@odata.count");
+ int members = count != null && count.isInt() ? count.asInt() : DirectoryTeam.COUNT_UNAVAILABLE;
+ return new DirectoryTeam(id, name, members);
+ }
+
+ /**
+ * One user. {@code mail} is preferred over {@code userPrincipalName} because it is what a
+ * person recognises, but an account can easily have only the latter -- a freshly invited
+ * guest, for one -- so the fallback is not optional.
+ */
+ static DirectoryUser user(JsonNode node, Set privilegedRoles) {
+ String mail = text(node, "mail");
+ String email = mail.isBlank() ? text(node, "userPrincipalName") : mail;
+ return new DirectoryUser(text(node, "id"), text(node, "displayName"), email, privilegedRoles);
+ }
+
+ /**
+ * The role names from a {@code memberOf} / {@code transitiveMemberOf} response, keeping only
+ * entries that really are directory roles. A group in that list is a group, not a role, and
+ * treating one as the other would either block an ordinary member from ever being helped or,
+ * far worse, let an administrator through because their role arrived shaped unexpectedly.
+ */
+ static Set directoryRoles(JsonNode body) {
+ Set roles = new LinkedHashSet<>();
+ for (JsonNode node : items(body)) {
+ String type = text(node, "@odata.type");
+ if (type.endsWith("directoryRole")) {
+ String name = text(node, "displayName");
+ if (!name.isBlank()) {
+ roles.add(name);
+ }
+ }
+ }
+ return roles;
+ }
+
+ /** A field as text, or empty -- never null, so no caller has to guard. */
+ static String text(JsonNode node, String field) {
+ if (node == null) {
+ return "";
+ }
+ JsonNode value = node.get(field);
+ return value == null || value.isNull() ? "" : value.asText("");
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndex.java b/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndex.java
new file mode 100644
index 0000000..717f72a
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndex.java
@@ -0,0 +1,117 @@
+/**
+ * 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.directory;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import net.onelitefeather.apus.operator.api.Tenant;
+
+/**
+ * Which identity-provider group belongs to which tenant, in both directions.
+ *
+ *
This is what turns a token into a tenant. Before it existed, {@code PrincipalResolver} read
+ * a claim named {@code organization} that the app registration never emitted -- neither {@code
+ * groupMembershipClaims} nor {@code optionalClaims} was configured on it -- so every signed-in
+ * user resolved to "no tenant" and the tenant application had nothing to show anyone. A group id
+ * is something the provider genuinely puts in a token.
+ *
+ *
It is also the source of {@link DirectoryGuard}'s managed-group set, and that is not a
+ * coincidence worth undoing: the groups Apus will act on and the groups Apus recognises members
+ * of must be the same set, or one of them would drift into being wider than the other.
+ *
+ *
Immutable. Rebuilt wholesale from the tenant list rather than mutated, so a reader either
+ * sees the whole old index or the whole new one.
+ */
+public final class TenantGroupIndex {
+
+ private final Map tenantByGroup;
+ private final Map groupByTenant;
+
+ private TenantGroupIndex(Map tenantByGroup, Map groupByTenant) {
+ this.tenantByGroup = Map.copyOf(tenantByGroup);
+ this.groupByTenant = Map.copyOf(groupByTenant);
+ }
+
+ /** An index over no tenants: recognises nobody, manages nothing. */
+ public static TenantGroupIndex empty() {
+ return new TenantGroupIndex(Map.of(), Map.of());
+ }
+
+ /**
+ * Builds the index from the tenants that currently exist.
+ *
+ *
Tenants without a configured group are skipped rather than mapped to a blank key: an
+ * unconfigured tenant must not become the one every group without a match falls into.
+ *
+ *
If two tenants somehow claim the same group -- which nothing prevents, since the field
+ * is free text on a custom resource -- the first by name wins and is stable across restarts.
+ * Deliberately not "last wins", which would make membership depend on list ordering, and
+ * deliberately not an exception, which would take the API down over a typo in one tenant.
+ */
+ public static TenantGroupIndex of(Iterable tenants) {
+ Map tenantByGroup = new LinkedHashMap<>();
+ Map groupByTenant = new LinkedHashMap<>();
+ for (Tenant tenant : tenants) {
+ String name = tenant.getMetadata().getName();
+ String group = tenant.getSpec().getIdentity().getGroupId();
+ if (group == null || group.isBlank()) {
+ continue;
+ }
+ String existing = tenantByGroup.get(group);
+ if (existing != null && existing.compareTo(name) <= 0) {
+ continue;
+ }
+ if (existing != null) {
+ groupByTenant.remove(existing);
+ }
+ tenantByGroup.put(group, name);
+ groupByTenant.put(name, group);
+ }
+ return new TenantGroupIndex(tenantByGroup, groupByTenant);
+ }
+
+ /**
+ * The tenant a signed-in user belongs to, given the groups their token carries.
+ *
+ *
A user in several mapped groups resolves to the alphabetically first tenant, which is at
+ * least stable; multi-tenant membership is not a thing this platform models, and picking
+ * arbitrarily would make someone's tenant change between requests.
+ */
+ public Optional tenantForGroups(Iterable groupIds) {
+ String best = null;
+ for (String groupId : groupIds) {
+ String tenant = tenantByGroup.get(groupId);
+ if (tenant != null && (best == null || tenant.compareTo(best) < 0)) {
+ best = tenant;
+ }
+ }
+ return Optional.ofNullable(best);
+ }
+
+ /** The group belonging to a tenant, or empty when it has none configured. */
+ public Optional groupForTenant(String tenant) {
+ return Optional.ofNullable(groupByTenant.get(tenant));
+ }
+
+ /** Every group some tenant claims -- exactly what {@link DirectoryGuard} may act on. */
+ public Set managedGroups() {
+ return tenantByGroup.keySet();
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndexLoader.java b/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndexLoader.java
new file mode 100644
index 0000000..9d59705
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndexLoader.java
@@ -0,0 +1,77 @@
+/**
+ * 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.directory;
+
+import io.micronaut.scheduling.annotation.Scheduled;
+import jakarta.inject.Singleton;
+import net.onelitefeather.apus.api.rest.tenant.TenantRepository;
+import net.onelitefeather.apus.api.support.PrincipalResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Keeps {@link TenantGroupIndex} current, and hands the same set of groups to both of its
+ * consumers: {@link PrincipalResolver}, which decides which tenant a signed-in user is in, and
+ * {@link DirectoryGuard}, which decides which groups Apus may act on.
+ *
+ *
Those two must be the same set. If they ever diverged, one of them would be wider than the
+ * other -- either recognising members of a group Apus refuses to manage, or managing a group
+ * whose members it does not recognise. Handing both from one place is what makes divergence
+ * impossible rather than merely unlikely.
+ *
+ *
Polled rather than watched. A tenant's group id changes about as often as a tenant is
+ * created, the list is small, and a poll cannot get stuck half-subscribed the way a watch can --
+ * the failure mode of a stalled watch here would be members silently failing to resolve, with
+ * nothing obviously broken to look at.
+ *
+ *
A failed refresh leaves the previous index in place. Not an empty one: the Kubernetes
+ * API being briefly unreachable must not log everybody out of their tenant. The very first load
+ * failing does leave the index empty, and that is correct -- there is nothing else it could
+ * honestly be.
+ */
+@Singleton
+public class TenantGroupIndexLoader {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TenantGroupIndexLoader.class);
+
+ private final TenantRepository tenants;
+ private final PrincipalResolver principals;
+ private final DirectoryGuard guard;
+
+ public TenantGroupIndexLoader(TenantRepository tenants, PrincipalResolver principals, DirectoryGuard guard) {
+ this.tenants = tenants;
+ this.principals = principals;
+ this.guard = guard;
+ refresh();
+ }
+
+ /** Rebuilds the index from the current tenant list and publishes it to both consumers. */
+ @Scheduled(fixedDelay = "60s")
+ public final void refresh() {
+ try {
+ TenantGroupIndex index = TenantGroupIndex.of(tenants.list());
+ principals.setGroupIndex(index);
+ guard.setManagedGroups(index.managedGroups());
+ LOGGER.debug("tenant group index refreshed: {} managed group(s)", index.managedGroups().size());
+ } catch (RuntimeException e) {
+ // Keep serving with what we had. Losing the index would sign everybody out of their
+ // tenant over a transient API-server hiccup.
+ LOGGER.warn("could not refresh the tenant group index; keeping the previous one", e);
+ }
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryRequests.java b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryRequests.java
new file mode 100644
index 0000000..aca3886
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryRequests.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.directory;
+
+import io.micronaut.serde.annotation.Serdeable;
+
+/** What the directory endpoints accept. */
+public final class DirectoryRequests {
+
+ private DirectoryRequests() {}
+
+ /** Create a team inside a tenant's group. */
+ @Serdeable
+ public record CreateTeamRequest(String displayName) {}
+
+ /**
+ * Invite somebody into a tenant's group.
+ *
+ * @param email the address the invitation goes to
+ * @param displayName what to call them; optional, and the local part of the address is used
+ * when it is absent rather than leaving a blank row in every list
+ */
+ @Serdeable
+ public record InviteUserRequest(String email, String displayName) {}
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryResponses.java b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryResponses.java
new file mode 100644
index 0000000..9adecb3
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryResponses.java
@@ -0,0 +1,92 @@
+/**
+ * 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.directory;
+
+import io.micronaut.serde.annotation.Serdeable;
+import java.util.List;
+import net.onelitefeather.apus.api.directory.DirectoryTeam;
+import net.onelitefeather.apus.api.directory.DirectoryUser;
+
+/** What the directory endpoints return. Kept together because they are read together. */
+public final class DirectoryResponses {
+
+ private DirectoryResponses() {}
+
+ /**
+ * A team.
+ *
+ * @param memberCount {@code null} rather than {@code 0} when the directory could be asked for
+ * the team but not for its size -- a zero meaning "not counted" is a lie somebody would
+ * act on, and {@code null} is the one value a UI cannot mistake for a number
+ */
+ @Serdeable
+ public record TeamResponse(String id, String displayName, Integer memberCount) {
+ public static TeamResponse from(DirectoryTeam team) {
+ return new TeamResponse(team.id(), team.displayName(), team.hasMemberCount() ? team.memberCount() : null);
+ }
+ }
+
+ /**
+ * A person.
+ *
+ * @param privileged whether this account holds a directory role that makes it un-resettable
+ * through Apus. Exposed so the console can grey the button out rather than let someone
+ * press it and receive a refusal -- the refusal is still what actually enforces it
+ */
+ @Serdeable
+ public record UserResponse(String id, String displayName, String email, boolean privileged) {
+ public static UserResponse from(DirectoryUser user) {
+ return new UserResponse(user.id(), user.displayName(), user.email(), user.isPrivileged());
+ }
+ }
+
+ /**
+ * The counts shown next to a tenant.
+ *
+ * @param teams number of teams, or {@code null} when the directory could not be asked
+ * @param users number of members, or {@code null} when the directory could not be asked
+ * @param unavailableReason why they are {@code null}, in words an administrator can act on.
+ * Present exactly when a count is missing -- a UI showing "unavailable" with no reason
+ * sends someone to read server logs for something the server already knew
+ */
+ @Serdeable
+ public record DirectoryCountsResponse(Integer teams, Integer users, String unavailableReason) {
+ public static DirectoryCountsResponse of(int teams, int users) {
+ return new DirectoryCountsResponse(teams, users, null);
+ }
+
+ public static DirectoryCountsResponse unavailable(String reason) {
+ return new DirectoryCountsResponse(null, null, reason);
+ }
+ }
+
+ /** The teams and members of one tenant, with the same "unavailable is not empty" rule. */
+ @Serdeable
+ public record TenantDirectoryResponse(
+ List teams, List users, String unavailableReason) {}
+
+ /**
+ * The result of a password reset.
+ *
+ *
{@code temporaryPassword} is the only place this value ever exists outside the identity
+ * provider. It is shown to a human once and is deliberately not stored, not logged, and not
+ * retrievable again -- the same rule the tenant push token follows.
+ */
+ @Serdeable
+ public record PasswordResetResponse(String userId, String temporaryPassword) {}
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryController.java
new file mode 100644
index 0000000..3705809
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryController.java
@@ -0,0 +1,253 @@
+/**
+ * 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.directory;
+
+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 java.util.Locale;
+import net.onelitefeather.apus.api.directory.Directory;
+import net.onelitefeather.apus.api.directory.DirectoryGuard;
+import net.onelitefeather.apus.api.directory.DirectoryTeam;
+import net.onelitefeather.apus.api.directory.DirectoryUnavailableException;
+import net.onelitefeather.apus.api.directory.DirectoryUser;
+import net.onelitefeather.apus.api.rest.directory.DirectoryRequests.CreateTeamRequest;
+import net.onelitefeather.apus.api.rest.directory.DirectoryRequests.InviteUserRequest;
+import net.onelitefeather.apus.api.rest.directory.DirectoryResponses.DirectoryCountsResponse;
+import net.onelitefeather.apus.api.rest.directory.DirectoryResponses.PasswordResetResponse;
+import net.onelitefeather.apus.api.rest.directory.DirectoryResponses.TeamResponse;
+import net.onelitefeather.apus.api.rest.directory.DirectoryResponses.TenantDirectoryResponse;
+import net.onelitefeather.apus.api.rest.directory.DirectoryResponses.UserResponse;
+import net.onelitefeather.apus.api.rest.support.BadRequestException;
+import net.onelitefeather.apus.api.rest.support.NotFoundException;
+import net.onelitefeather.apus.api.rest.tenant.TenantRepository;
+import net.onelitefeather.apus.api.security.ApusPrincipal;
+import net.onelitefeather.apus.api.support.PrincipalResolver;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A tenant's teams and people: how many there are, who they are, and the four things an
+ * administrator can change.
+ *
+ *
Every method calls {@link DirectoryGuard} before it calls {@link Directory}. That
+ * ordering is the whole security model of this controller. The Graph permissions behind these
+ * operations are directory-wide -- Entra offers no narrower variant -- so the guard is what keeps
+ * them pointed at groups a {@code Tenant} actually claims, and nothing in {@link Directory} will
+ * refuse on its own.
+ *
+ *
A tenant nobody may see is a 404, not a 403, matching the rest of this module: a
+ * tenant-owner probing for other tenants must not be able to tell "exists but forbidden" from
+ * "does not exist".
+ *
+ *
The directory being down is not this page failing. Reads catch {@link
+ * DirectoryUnavailableException} and report the panel unavailable with a reason, so a tenant
+ * whose storage and renders are fine stays readable while Microsoft is throttling. Writes do not:
+ * an invitation that silently did not happen would be far worse than an error.
+ */
+@Controller("/api/tenants/{tenant}/directory")
+@Secured(SecurityRule.IS_AUTHENTICATED)
+public class TenantDirectoryController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TenantDirectoryController.class);
+
+ private final TenantRepository tenants;
+ private final Directory directory;
+ private final DirectoryGuard guard;
+ private final PrincipalResolver principals;
+
+ public TenantDirectoryController(
+ TenantRepository tenants, Directory directory, DirectoryGuard guard, PrincipalResolver principals) {
+ this.tenants = tenants;
+ this.directory = directory;
+ this.guard = guard;
+ this.principals = principals;
+ }
+
+ /** Counts for the tenant list. Never fails the page -- see the class Javadoc. */
+ @Get("/counts")
+ public HttpResponse counts(Authentication authentication, @PathVariable String tenant) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantAccess(principal, tenant, group);
+ try {
+ return HttpResponse.ok(DirectoryCountsResponse.of(
+ directory.teamsIn(group).size(), directory.membersOf(group).size()));
+ } catch (DirectoryUnavailableException e) {
+ LOGGER.warn("directory counts unavailable for tenant '{}': {}", tenant, e.getMessage());
+ return HttpResponse.ok(DirectoryCountsResponse.unavailable(e.getMessage()));
+ }
+ }
+
+ /** The teams and members of one tenant. */
+ @Get
+ public HttpResponse read(Authentication authentication, @PathVariable String tenant) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantAccess(principal, tenant, group);
+ try {
+ List teams =
+ directory.teamsIn(group).stream().map(TeamResponse::from).toList();
+ List users =
+ directory.membersOf(group).stream().map(UserResponse::from).toList();
+ return HttpResponse.ok(new TenantDirectoryResponse(teams, users, null));
+ } catch (DirectoryUnavailableException e) {
+ LOGGER.warn("directory unavailable for tenant '{}': {}", tenant, e.getMessage());
+ return HttpResponse.ok(new TenantDirectoryResponse(List.of(), List.of(), e.getMessage()));
+ }
+ }
+
+ /**
+ * Who is in one team — the assignment, rather than the two separate lists.
+ *
+ *
The team must be a team of this tenant, checked by listing the tenant's teams
+ * first rather than by trusting the id in the path. Without that, the group guard would pass
+ * (the tenant's own group is managed) while the id pointed at any group in the directory, and
+ * this would become a way to read the membership of every group in the organisation.
+ */
+ @Get("/teams/{teamId}/members")
+ public HttpResponse> teamMembers(
+ Authentication authentication, @PathVariable String tenant, @PathVariable String teamId) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantAccess(principal, tenant, group);
+
+ boolean belongsToThisTenant =
+ directory.teamsIn(group).stream().anyMatch(team -> team.id().equals(teamId));
+ if (!belongsToThisTenant) {
+ throw new NotFoundException("no such team in tenant '" + tenant + "'");
+ }
+ return HttpResponse.ok(
+ directory.membersOf(teamId).stream().map(UserResponse::from).toList());
+ }
+
+ @Post("/teams")
+ public HttpResponse createTeam(
+ Authentication authentication, @PathVariable String tenant, @Body CreateTeamRequest request) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantWrite(principal, tenant, group);
+
+ String displayName = request == null || request.displayName() == null
+ ? ""
+ : request.displayName().trim();
+ if (displayName.isEmpty()) {
+ throw new BadRequestException("a team needs a name");
+ }
+ // Logged before the call, so an attempt that fails is on the record too.
+ LOGGER.info("'{}' is creating team '{}' in tenant '{}'", principal.subject(), displayName, tenant);
+ DirectoryTeam team = directory.createTeam(group, displayName);
+ return HttpResponse.created(TeamResponse.from(team));
+ }
+
+ @Post("/invitations")
+ public HttpResponse invite(
+ Authentication authentication, @PathVariable String tenant, @Body InviteUserRequest request) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantWrite(principal, tenant, group);
+
+ String email =
+ request == null || request.email() == null ? "" : request.email().trim();
+ if (!looksLikeAnAddress(email)) {
+ throw new BadRequestException("an invitation needs an e-mail address");
+ }
+ String displayName = request.displayName() == null
+ || request.displayName().isBlank()
+ ? email.substring(0, email.indexOf('@'))
+ : request.displayName().trim();
+
+ LOGGER.info("'{}' is inviting '{}' into tenant '{}'", principal.subject(), email, tenant);
+ return HttpResponse.created(UserResponse.from(directory.invite(group, email, displayName)));
+ }
+
+ /**
+ * Resets a member's password and returns the temporary one.
+ *
+ *
Four checks stand between a request and a changed password, and the order matters: the
+ * group must be one Apus manages, the caller must be able to write in this tenant, the target
+ * must actually be a member of it, and only then may the guard weigh in on who the target
+ * is. Doing the membership check before fetching roles also means a caller cannot
+ * use this endpoint to probe which accounts hold privileged roles.
+ */
+ @Post("/users/{userId}/password-reset")
+ public HttpResponse resetPassword(
+ Authentication authentication, @PathVariable String tenant, @PathVariable String userId) {
+ ApusPrincipal principal = principals.resolve(authentication);
+ String group = groupOf(principal, tenant);
+ guard.requireTenantWrite(principal, tenant, group);
+
+ boolean isMember =
+ directory.membersOf(group).stream().anyMatch(member -> member.id().equals(userId));
+ if (!isMember) {
+ // 404, not 403: whether an account exists elsewhere in the directory is not something
+ // this endpoint should confirm.
+ throw new NotFoundException("no such member of tenant '" + tenant + "'");
+ }
+
+ DirectoryUser target = directory.findUser(userId);
+ if (target == null) {
+ throw new NotFoundException("no such member of tenant '" + tenant + "'");
+ }
+ guard.requirePasswordResetAllowed(principal, target);
+
+ LOGGER.info("'{}' is resetting the password of '{}' in tenant '{}'", principal.subject(), userId, tenant);
+ return HttpResponse.ok(new PasswordResetResponse(userId, directory.resetPassword(userId)));
+ }
+
+ /**
+ * The tenant's identity group, or a {@code 404} if there is no such tenant.
+ *
+ *
A tenant that exists but has no group configured is deliberately not a 404 --
+ * it is a real tenant, and the guard's message about a missing identity group is the useful
+ * answer. Reporting "no such tenant" there would send an administrator looking for the wrong
+ * problem entirely.
+ */
+ private String groupOf(ApusPrincipal principal, String tenantName) {
+ Tenant tenant = tenants.findByName(tenantName).orElseThrow(() -> {
+ if (!principal.isPlatformAdmin()) {
+ return new NotFoundException("no such tenant");
+ }
+ return new NotFoundException("no such tenant: " + tenantName);
+ });
+ return tenant.getSpec().getIdentity().getGroupId();
+ }
+
+ /**
+ * Enough of an address check to catch a mistyped field, and no more. The identity provider
+ * does the real validation, and a stricter pattern here would reject addresses that are
+ * perfectly valid before the invitation ever reaches it.
+ */
+ private static boolean looksLikeAnAddress(String email) {
+ int at = email.indexOf('@');
+ return at > 0
+ && at < email.length() - 1
+ && email.indexOf('@', at + 1) < 0
+ && email.lastIndexOf('.') > at
+ && !email.contains(" ")
+ && email.equals(email.toLowerCase(Locale.ROOT).trim());
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/Impersonation.java b/api/src/main/java/net/onelitefeather/apus/api/security/Impersonation.java
new file mode 100644
index 0000000..e66e3a8
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/security/Impersonation.java
@@ -0,0 +1,42 @@
+/**
+ * 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;
+
+/**
+ * One request being served as somebody else.
+ *
+ * @param realSubject who actually authenticated -- kept for the audit trail, and never replaced.
+ * Every log line about an impersonated request names this, not the effective principal:
+ * "someone did X" is useless if the someone is the person they were pretending to be
+ * @param effective who the request is authorised as. Always a narrowing of {@link #realSubject}'s
+ * own authority -- see {@link ImpersonationPolicy}
+ */
+public record Impersonation(String realSubject, ApusPrincipal effective) {
+
+ public Impersonation {
+ Objects.requireNonNull(realSubject, "realSubject must not be null");
+ Objects.requireNonNull(effective, "effective must not be null");
+ }
+
+ /** A short, log-safe description: {@code root as tenant-owner of acme}. */
+ public String describe() {
+ return realSubject + " acting as '" + effective.subject() + "' in tenant '" + effective.tenant() + "'";
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationFilter.java b/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationFilter.java
new file mode 100644
index 0000000..20b83bd
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationFilter.java
@@ -0,0 +1,139 @@
+/**
+ * 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 io.micronaut.core.annotation.Order;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.annotation.RequestFilter;
+import io.micronaut.http.annotation.ServerFilter;
+import io.micronaut.security.authentication.Authentication;
+import io.micronaut.security.filters.SecurityFilter;
+import java.util.List;
+import java.util.Map;
+import net.onelitefeather.apus.api.support.PrincipalResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Applies {@link ImpersonationPolicy} to a request that asks to be served as somebody else.
+ *
+ *
Two headers, both optional:
+ *
+ *
+ *
{@code X-Apus-Act-As-Tenant} -- the tenant to act within. Required to impersonate at all.
+ *
{@code X-Apus-Act-As-User} -- the person to appear as. Omit to act as the tenant itself,
+ * which is the "as org admin" case.
+ *
+ *
+ *
Done in a filter, once, rather than in each controller. Every controller in this
+ * module already resolves its caller through {@link PrincipalResolver}; replacing the request's
+ * {@link Authentication} here means all of them -- including ones written later by someone who
+ * has never heard of this feature -- see the impersonated principal and enforce their own rules
+ * against it, with no chance of one forgetting.
+ *
+ *
A refused impersonation fails the request. It does not quietly fall back to the real
+ * principal: someone who asked to act as a tenant and was served as themselves would read the
+ * answer as that tenant's, which is a worse outcome than an error.
+ *
+ *
Every impersonated request is logged with the real subject before it is served.
+ */
+@ServerFilter("/api/**")
+// Explicitly after Micronaut's own security filter, because this reads the Authentication that
+// filter puts on the request. Left to the default ordering it could run first, find no
+// authentication, and refuse every impersonated request -- a failure that would look like a
+// permission problem and send somebody looking at roles.
+//
+// A literal because an annotation value must be a constant expression and
+// ServerFilterPhase.SECURITY.after() is a method call. ImpersonationFilterOrderTest asserts the
+// two agree, so a Micronaut release that renumbers the phases fails a test rather than silently
+// reordering this filter.
+@Order(ImpersonationFilter.ORDER)
+public class ImpersonationFilter {
+
+ /** {@code ServerFilterPhase.SECURITY.after()}; see the {@link Order} annotation above. */
+ public static final int ORDER = 39250;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ImpersonationFilter.class);
+
+ /** The tenant to act within. Without it, nothing here happens at all. */
+ public static final String TENANT_HEADER = "X-Apus-Act-As-Tenant";
+
+ /** The person to appear as; optional, see the class Javadoc. */
+ public static final String USER_HEADER = "X-Apus-Act-As-User";
+
+ /**
+ * Where the effective principal is published. Set as an attribute rather than only mutating
+ * the authentication so an audit or telemetry consumer can tell an impersonated request from
+ * an ordinary one without re-deriving it.
+ */
+ public static final String IMPERSONATION_ATTRIBUTE = "apus.impersonation";
+
+ private final ImpersonationPolicy policy;
+ private final PrincipalResolver principals;
+
+ public ImpersonationFilter(ImpersonationPolicy policy, PrincipalResolver principals) {
+ this.policy = policy;
+ this.principals = principals;
+ }
+
+ @RequestFilter
+ public void filter(HttpRequest> request) {
+ String tenant = request.getHeaders().get(TENANT_HEADER);
+ if (tenant == null || tenant.isBlank()) {
+ return;
+ }
+
+ Authentication authentication =
+ request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class).orElse(null);
+ if (authentication == null) {
+ // Not authenticated at all: the security filter will refuse this request on its own,
+ // and impersonating on behalf of nobody is not a thing to attempt.
+ throw new ForbiddenException("impersonation requires an authenticated caller");
+ }
+
+ ApusPrincipal real = principals.resolve(authentication);
+ String user = request.getHeaders().get(USER_HEADER);
+ Impersonation impersonation = policy.resolve(real, tenant.trim(), user == null ? null : user.trim());
+
+ // Logged before the request is served, so an attempt that then fails is on the record.
+ LOGGER.info("{} -> {} {}", impersonation.describe(), request.getMethodName(), request.getPath());
+
+ request.setAttribute(IMPERSONATION_ATTRIBUTE, impersonation);
+ request.setAttribute(SecurityFilter.AUTHENTICATION, asAuthentication(impersonation));
+ }
+
+ /**
+ * The effective principal as an {@link Authentication}, so {@link PrincipalResolver} resolves
+ * it the same way it resolves a real one and no controller needs a second code path.
+ *
+ *
The tenant is carried as the explicit organisation claim rather than as groups: it has
+ * already been decided by the policy, and re-deriving it from group membership would let the
+ * group index have an opinion about a decision that was already made.
+ */
+ private static Authentication asAuthentication(Impersonation impersonation) {
+ ApusPrincipal effective = impersonation.effective();
+ List roles =
+ effective.roles().stream().map(Role::claimValue).toList();
+ return Authentication.build(
+ effective.subject(),
+ roles,
+ Map.of(
+ PrincipalResolver.TENANT_CLAIM, effective.tenant(),
+ "apus_act_as_by", impersonation.realSubject()));
+ }
+}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationPolicy.java b/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationPolicy.java
new file mode 100644
index 0000000..039d071
--- /dev/null
+++ b/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationPolicy.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.security;
+
+import jakarta.inject.Singleton;
+import java.util.Set;
+
+/**
+ * Who may act as whom, and with what.
+ *
+ *
Impersonation exists so an administrator can see what a tenant sees -- a support question
+ * that is otherwise answered by guessing. It is also, obviously, a way to act with somebody
+ * else's authority, so the whole design rests on one rule:
+ *
+ *
Impersonation only ever narrows. The effective principal never holds {@link
+ * Role#PLATFORM_ADMIN}, and never holds a role its real caller does not. There is no combination
+ * of headers that lets anyone do something they could not already do as themselves; the only
+ * thing it changes is which tenant they are doing it in. That is what makes this
+ * feature's blast radius the same as its caller's, and it is why the policy strips the platform
+ * role rather than checking for its absence.
+ *
+ *
Pure and separate from the filter that applies it, because this is the part worth reading
+ * carefully and the part every test aims at.
+ */
+@Singleton
+public class ImpersonationPolicy {
+
+ /**
+ * Resolves the principal a request should be served as.
+ *
+ * @param real who actually authenticated
+ * @param targetTenant the tenant to act within; required
+ * @param targetSubject the person to appear as, or {@code null} to act as the tenant itself
+ * ("as org admin") rather than as a named member
+ * @return the effective principal, always a narrowing of {@code real}'s authority
+ * @throws ForbiddenException when the caller may not act in that tenant at all
+ */
+ public Impersonation resolve(ApusPrincipal real, String targetTenant, String targetSubject) {
+ if (targetTenant == null || targetTenant.isBlank()) {
+ throw new ForbiddenException("impersonation needs a tenant to act in");
+ }
+
+ boolean allowed = real.isPlatformAdmin()
+ // A tenant-owner may act within their own tenant -- that is the "org admin" case,
+ // and it grants nothing they did not already have there.
+ || (real.roles().contains(Role.TENANT_OWNER) && targetTenant.equals(real.tenant()));
+ if (!allowed) {
+ throw new ForbiddenException("not allowed to act within tenant '" + targetTenant + "'");
+ }
+
+ // Tenant-owner and no more. Never PLATFORM_ADMIN: an impersonated session that carried
+ // the platform role would let someone reach every other tenant while wearing a tenant
+ // member's name, which is the exact opposite of what an audit trail is for.
+ Set effectiveRoles = Set.of(Role.TENANT_OWNER);
+
+ String subject = targetSubject == null || targetSubject.isBlank() ? real.subject() : targetSubject;
+ return new Impersonation(real.subject(), new ApusPrincipal(subject, targetTenant, effectiveRoles));
+ }
+}
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
index cb45232..b082ed5 100644
--- a/api/src/main/java/net/onelitefeather/apus/api/security/Role.java
+++ b/api/src/main/java/net/onelitefeather/apus/api/security/Role.java
@@ -66,4 +66,17 @@ public static Optional fromClaim(String claim) {
default -> Optional.empty();
};
}
+
+ /**
+ * The claim value this role appears as in a token -- the exact inverse of {@link
+ * #fromClaim(String)}.
+ *
+ *
Needed because impersonation builds an {@code Authentication} of its own and must spell
+ * the roles the way {@link #fromClaim} will read them back. Derived from the enum name rather
+ * than written out a second time, so the two cannot drift: a fifth role added to this enum is
+ * spelled correctly here without anybody remembering to come back.
+ */
+ public String claimValue() {
+ return name().toLowerCase(Locale.ROOT).replace('_', '-');
+ }
}
diff --git a/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java
index e05e712..4e4dd2e 100644
--- a/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java
+++ b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java
@@ -19,9 +19,13 @@
import io.micronaut.security.authentication.Authentication;
import jakarta.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Objects;
import java.util.Set;
+import net.onelitefeather.apus.api.directory.TenantGroupIndex;
import net.onelitefeather.apus.api.security.ApusPrincipal;
import net.onelitefeather.apus.api.security.Role;
@@ -57,6 +61,33 @@ public class PrincipalResolver {
/** See the class Javadoc for why this specific claim name. */
public static final String TENANT_CLAIM = "organization";
+ /**
+ * The claim carrying the caller's identity-provider group memberships.
+ *
+ *
Added because {@link #TENANT_CLAIM} turned out never to be emitted at all. The app
+ * registration this platform authenticates against had neither {@code groupMembershipClaims}
+ * nor {@code optionalClaims} configured, so {@code organization} was absent from every token
+ * and every user resolved to "no tenant" -- which is exactly what the tenant application
+ * showed everybody. A group id is something a provider genuinely puts in a token, and
+ * {@link TenantGroupIndex} maps it back to a tenant.
+ */
+ public static final String GROUPS_CLAIM = "groups";
+
+ /**
+ * Which group belongs to which tenant. Replaced wholesale by {@link TenantGroupIndexLoader}
+ * as tenants change; {@code volatile} so a request thread sees a replacement promptly, and
+ * safe to swap under readers because the index itself is immutable.
+ *
+ *
Starts empty, which means "no tenant" for everyone until it is loaded. That is the right
+ * direction to fail: identifying nobody is recoverable, inventing a tenant is not.
+ */
+ private volatile TenantGroupIndex groupIndex = TenantGroupIndex.empty();
+
+ /** Replaces the group index. Called by the loader, never during request handling. */
+ public void setGroupIndex(TenantGroupIndex groupIndex) {
+ this.groupIndex = groupIndex == null ? TenantGroupIndex.empty() : groupIndex;
+ }
+
/**
* @param authentication the token-derived authentication Micronaut Security already
* validated (signature, issuer) before this method ever sees it
@@ -75,6 +106,35 @@ public ApusPrincipal resolve(Authentication authentication) {
Object tenantClaim = authentication.getAttributes().get(TENANT_CLAIM);
String tenant = tenantClaim instanceof String value ? value : null;
+ // An explicit organisation claim still wins where a broker emits one: a platform that
+ // configured that claim must not have its meaning quietly overridden by group membership.
+ // Groups are the fallback, and in practice the only one that fires.
+ if (tenant == null || tenant.isBlank()) {
+ tenant = groupIndex.tenantForGroups(groupsOf(authentication)).orElse(null);
+ }
+
return new ApusPrincipal(authentication.getName(), tenant, roles);
}
+
+ /**
+ * The group ids in the token, or an empty list for anything that is not a list of strings.
+ *
+ *
Tolerant on purpose. A claim of an unexpected shape must not throw on every request; it
+ * must simply fail to identify a tenant, which is the same outcome as carrying no groups at
+ * all -- and it is what happens once a user exceeds the provider's group limit, at which
+ * point Entra replaces the list with a {@code _claim_names} pointer rather than sending it.
+ */
+ private static List groupsOf(Authentication authentication) {
+ Object claim = authentication.getAttributes().get(GROUPS_CLAIM);
+ if (!(claim instanceof Collection> values)) {
+ return List.of();
+ }
+ List groups = new ArrayList<>();
+ for (Object value : values) {
+ if (value instanceof String group && !group.isBlank()) {
+ groups.add(group);
+ }
+ }
+ return groups;
+ }
}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/directory/DirectoryGuardTest.java b/api/src/test/java/net/onelitefeather/apus/api/directory/DirectoryGuardTest.java
new file mode 100644
index 0000000..9b9f6f8
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/directory/DirectoryGuardTest.java
@@ -0,0 +1,172 @@
+/**
+ * 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.directory;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.ForbiddenException;
+import net.onelitefeather.apus.api.security.Role;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Written from the attacker's side. The Graph permissions behind these operations are
+ * directory-wide -- Entra offers no "these groups only" variant of {@code Group.ReadWrite.All} --
+ * so this guard is the only thing between a bug in a controller and every account in the
+ * organisation. Each test names what someone would be trying to do, not what the happy path is.
+ */
+class DirectoryGuardTest {
+
+ private static final String ACME_GROUP = "11111111-1111-1111-1111-111111111111";
+ private static final String GLOBEX_GROUP = "22222222-2222-2222-2222-222222222222";
+ private static final String UNCLAIMED_GROUP = "99999999-9999-9999-9999-999999999999";
+
+ private final DirectoryGuard guard = new DirectoryGuard();
+
+ private static ApusPrincipal owner(String tenant) {
+ return new ApusPrincipal("alice", tenant, Set.of(Role.TENANT_OWNER));
+ }
+
+ private static ApusPrincipal platformAdmin() {
+ return new ApusPrincipal("root", null, Set.of(Role.PLATFORM_ADMIN));
+ }
+
+ private static ApusPrincipal viewer(String tenant) {
+ return new ApusPrincipal("bob", tenant, Set.of(Role.TENANT_VIEWER));
+ }
+
+ // --- groups Apus has no business touching -------------------------------------------------
+
+ @Test
+ void refusesAGroupNoTenantClaims() {
+ // The single most important rule here. A group nobody named in a Tenant is not Apus's to
+ // read, rename or add anyone to -- and with a directory-wide permission, "not ours" is
+ // otherwise indistinguishable from "ours".
+ assertThrows(ForbiddenException.class, () -> guard.requireManagedGroup(platformAdmin(), UNCLAIMED_GROUP));
+ }
+
+ @Test
+ void refusesAnEmptyOrNullGroup() {
+ // An unconfigured tenant must not become the widest one on the platform.
+ assertThrows(ForbiddenException.class, () -> guard.requireManagedGroup(platformAdmin(), null));
+ assertThrows(ForbiddenException.class, () -> guard.requireManagedGroup(platformAdmin(), " "));
+ }
+
+ @Test
+ void allowsAGroupThatATenantClaims() {
+ guard.setManagedGroups(Set.of(ACME_GROUP, GLOBEX_GROUP));
+
+ assertDoesNotThrow(() -> guard.requireManagedGroup(platformAdmin(), ACME_GROUP));
+ }
+
+ // --- one tenant reaching into another ------------------------------------------------------
+
+ @Test
+ void refusesATenantOwnerReachingIntoAnotherTenantsGroup() {
+ guard.setManagedGroups(Set.of(ACME_GROUP, GLOBEX_GROUP));
+
+ assertThrows(
+ ForbiddenException.class, () -> guard.requireTenantAccess(owner("acme"), "globex", GLOBEX_GROUP));
+ }
+
+ @Test
+ void allowsATenantOwnerWithinItsOwnTenant() {
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+
+ assertDoesNotThrow(() -> guard.requireTenantAccess(owner("acme"), "acme", ACME_GROUP));
+ }
+
+ @Test
+ void allowsAPlatformAdminAnywhereAmongManagedGroups() {
+ guard.setManagedGroups(Set.of(ACME_GROUP, GLOBEX_GROUP));
+
+ assertDoesNotThrow(() -> guard.requireTenantAccess(platformAdmin(), "globex", GLOBEX_GROUP));
+ }
+
+ @Test
+ void refusesAViewerEvenInsideItsOwnTenant() {
+ // Reading is one thing; these endpoints change the directory. A viewer has no business
+ // creating teams or resetting anyone's password.
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+
+ assertThrows(ForbiddenException.class, () -> guard.requireTenantWrite(viewer("acme"), "acme", ACME_GROUP));
+ }
+
+ @Test
+ void refusesAPrincipalWithNoTenantAndNoPlatformRole() {
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+ ApusPrincipal stranger = new ApusPrincipal("nobody", null, Set.of(Role.TENANT_OWNER));
+
+ assertThrows(ForbiddenException.class, () -> guard.requireTenantAccess(stranger, "acme", ACME_GROUP));
+ }
+
+ // --- password reset, where the damage is worst ---------------------------------------------
+
+ @Test
+ void refusesResettingThePasswordOfAGlobalAdministrator() {
+ // The escalation this whole design exists to prevent: an Apus tenant-owner taking over a
+ // directory administrator by resetting their password through a console button.
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+ DirectoryUser admin =
+ new DirectoryUser("u-admin", "Root", "root@example.net", Set.of("Global Administrator"));
+
+ ForbiddenException thrown = assertThrows(
+ ForbiddenException.class, () -> guard.requirePasswordResetAllowed(owner("acme"), admin));
+ assertTrue(thrown.getMessage().toLowerCase().contains("privileged"));
+ }
+
+ @Test
+ void refusesResettingAnyPrivilegedRoleNotJustGlobalAdministrator() {
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+ DirectoryUser helpdesk =
+ new DirectoryUser("u-help", "Helpdesk", "help@example.net", Set.of("User Administrator"));
+
+ assertThrows(ForbiddenException.class, () -> guard.requirePasswordResetAllowed(owner("acme"), helpdesk));
+ }
+
+ @Test
+ void refusesResettingYourOwnPassword() {
+ // Not an escalation, but not what this permission was granted for either: a self-service
+ // password change goes through the identity provider, where it is challenged properly.
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+ DirectoryUser self = DirectoryUser.member("alice", "Alice", "alice@example.net");
+
+ assertThrows(ForbiddenException.class, () -> guard.requirePasswordResetAllowed(owner("acme"), self));
+ }
+
+ @Test
+ void allowsResettingAnOrdinaryMember() {
+ guard.setManagedGroups(Set.of(ACME_GROUP));
+ DirectoryUser member = DirectoryUser.member("u-1", "Carol", "carol@example.net");
+
+ assertDoesNotThrow(() -> guard.requirePasswordResetAllowed(owner("acme"), member));
+ }
+
+ // --- the default, which must be closed ------------------------------------------------------
+
+ @Test
+ void managesNothingUntilToldOtherwise() {
+ // A guard that has not been given the managed-group set yet must refuse everything, not
+ // permit everything. This is the state it is in for the first moments after startup, and
+ // the state it stays in if the tenant index ever fails to load.
+ assertThrows(ForbiddenException.class, () -> guard.requireManagedGroup(platformAdmin(), ACME_GROUP));
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/directory/GraphResponsesTest.java b/api/src/test/java/net/onelitefeather/apus/api/directory/GraphResponsesTest.java
new file mode 100644
index 0000000..bdb1927
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/directory/GraphResponsesTest.java
@@ -0,0 +1,115 @@
+/**
+ * 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.directory;
+
+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 com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class GraphResponsesTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static JsonNode json(String raw) {
+ try {
+ return MAPPER.readTree(raw);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Test
+ void readsAValueArray() {
+ assertEquals(2, GraphResponses.items(json("{\"value\":[{\"id\":\"a\"},{\"id\":\"b\"}]}"))
+ .size());
+ }
+
+ @Test
+ void toleratesAResponseWithNoValueArrayAtAll() {
+ // Graph omits `value` on some error and metadata shapes. A listing that threw here would
+ // take down a page that has plenty else to show.
+ assertTrue(GraphResponses.items(json("{}")).isEmpty());
+ assertTrue(GraphResponses.items(null).isEmpty());
+ }
+
+ @Test
+ void aTeamWithoutACountSaysSoRatherThanClaimingZero() {
+ // A zero that means "we did not ask" is a lie an administrator would act on.
+ DirectoryTeam team = GraphResponses.team(json("{\"id\":\"g1\",\"displayName\":\"Builders\"}"));
+
+ assertFalse(team.hasMemberCount());
+ assertEquals(DirectoryTeam.COUNT_UNAVAILABLE, team.memberCount());
+ }
+
+ @Test
+ void aTeamWithACountKeepsIt() {
+ DirectoryTeam team =
+ GraphResponses.team(json("{\"id\":\"g1\",\"displayName\":\"Builders\",\"members@odata.count\":7}"));
+
+ assertTrue(team.hasMemberCount());
+ assertEquals(7, team.memberCount());
+ }
+
+ @Test
+ void prefersMailButFallsBackToTheUserPrincipalName() {
+ DirectoryUser withMail = GraphResponses.user(
+ json("{\"id\":\"u1\",\"displayName\":\"Alice\",\"mail\":\"alice@example.net\","
+ + "\"userPrincipalName\":\"alice_ext#EXT#@example.net\"}"),
+ Set.of());
+ assertEquals("alice@example.net", withMail.email());
+
+ // A freshly invited guest has no `mail` at all -- the fallback is not optional.
+ DirectoryUser guest = GraphResponses.user(
+ json("{\"id\":\"u2\",\"displayName\":\"Bob\",\"mail\":null,"
+ + "\"userPrincipalName\":\"bob_ext#EXT#@example.net\"}"),
+ Set.of());
+ assertEquals("bob_ext#EXT#@example.net", guest.email());
+ }
+
+ @Test
+ void keepsDirectoryRolesAndDropsGroups() {
+ // The list a user's memberOf returns mixes both. Treating a group as a role would block
+ // ordinary members from ever being helped; treating a role as a group would let an
+ // administrator's password be reset, which is the failure that matters.
+ Set roles = GraphResponses.directoryRoles(json("{\"value\":["
+ + "{\"@odata.type\":\"#microsoft.graph.directoryRole\",\"displayName\":\"Global Administrator\"},"
+ + "{\"@odata.type\":\"#microsoft.graph.group\",\"displayName\":\"Builders\"}"
+ + "]}"));
+
+ assertEquals(Set.of("Global Administrator"), roles);
+ }
+
+ @Test
+ void aRoleWithNoNameIsNotARole() {
+ assertTrue(GraphResponses.directoryRoles(
+ json("{\"value\":[{\"@odata.type\":\"#microsoft.graph.directoryRole\"}]}"))
+ .isEmpty());
+ }
+
+ @Test
+ void aMissingFieldReadsAsEmptyRatherThanNull() {
+ assertEquals("", GraphResponses.text(json("{}"), "displayName"));
+ assertEquals("", GraphResponses.text(json("{\"displayName\":null}"), "displayName"));
+ assertEquals("", GraphResponses.text(null, "displayName"));
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/directory/TenantGroupIndexTest.java b/api/src/test/java/net/onelitefeather/apus/api/directory/TenantGroupIndexTest.java
new file mode 100644
index 0000000..6875d55
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/directory/TenantGroupIndexTest.java
@@ -0,0 +1,98 @@
+/**
+ * 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.directory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Set;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.junit.jupiter.api.Test;
+
+class TenantGroupIndexTest {
+
+ private static Tenant tenant(String name, String groupId) {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName(name);
+ tenant.getSpec().getIdentity().setGroupId(groupId);
+ return tenant;
+ }
+
+ @Test
+ void mapsAGroupToItsTenant() {
+ TenantGroupIndex index = TenantGroupIndex.of(List.of(tenant("acme", "g-acme")));
+
+ assertEquals("acme", index.tenantForGroups(List.of("g-acme")).orElseThrow());
+ assertEquals("g-acme", index.groupForTenant("acme").orElseThrow());
+ assertEquals(Set.of("g-acme"), index.managedGroups());
+ }
+
+ @Test
+ void skipsATenantWithNoGroupRatherThanMappingABlankKey() {
+ // An unconfigured tenant must not become the one every unmatched group falls into.
+ TenantGroupIndex index = TenantGroupIndex.of(List.of(tenant("acme", null), tenant("globex", " ")));
+
+ assertTrue(index.managedGroups().isEmpty());
+ assertTrue(index.groupForTenant("acme").isEmpty());
+ }
+
+ @Test
+ void ignoresGroupsNoTenantClaims() {
+ TenantGroupIndex index = TenantGroupIndex.of(List.of(tenant("acme", "g-acme")));
+
+ assertTrue(index.tenantForGroups(List.of("g-someone-else")).isEmpty());
+ }
+
+ @Test
+ void aUserInNoGroupAtAllHasNoTenant() {
+ TenantGroupIndex index = TenantGroupIndex.of(List.of(tenant("acme", "g-acme")));
+
+ assertTrue(index.tenantForGroups(List.of()).isEmpty());
+ }
+
+ @Test
+ void resolvesTheSameWayEveryTimeWhenAUserIsInSeveralTenantsGroups() {
+ // Multi-tenant membership is not modelled. Picking arbitrarily would make somebody's
+ // tenant change between two requests, which is far worse than picking one and sticking
+ // to it.
+ TenantGroupIndex index =
+ TenantGroupIndex.of(List.of(tenant("zeta", "g-zeta"), tenant("acme", "g-acme")));
+
+ assertEquals("acme", index.tenantForGroups(List.of("g-zeta", "g-acme")).orElseThrow());
+ assertEquals("acme", index.tenantForGroups(List.of("g-acme", "g-zeta")).orElseThrow());
+ }
+
+ @Test
+ void survivesTwoTenantsClaimingTheSameGroup() {
+ // Nothing prevents it -- the field is free text on a custom resource. Taking the API down
+ // over a typo in one tenant would be a much worse answer than picking deterministically.
+ TenantGroupIndex index =
+ TenantGroupIndex.of(List.of(tenant("zeta", "shared"), tenant("acme", "shared")));
+
+ assertEquals("acme", index.tenantForGroups(List.of("shared")).orElseThrow());
+ assertEquals(Set.of("shared"), index.managedGroups());
+ assertTrue(index.groupForTenant("zeta").isEmpty(), "the loser must not keep a mapping back to the group");
+ }
+
+ @Test
+ void anEmptyIndexRecognisesNobodyAndManagesNothing() {
+ assertTrue(TenantGroupIndex.empty().managedGroups().isEmpty());
+ assertTrue(TenantGroupIndex.empty().tenantForGroups(List.of("g-acme")).isEmpty());
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/directory/InMemoryDirectory.java b/api/src/test/java/net/onelitefeather/apus/api/rest/directory/InMemoryDirectory.java
new file mode 100644
index 0000000..9b907ce
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/rest/directory/InMemoryDirectory.java
@@ -0,0 +1,114 @@
+/**
+ * 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.directory;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import net.onelitefeather.apus.api.directory.Directory;
+import net.onelitefeather.apus.api.directory.DirectoryTeam;
+import net.onelitefeather.apus.api.directory.DirectoryUnavailableException;
+import net.onelitefeather.apus.api.directory.DirectoryUser;
+
+/**
+ * A directory in a map, so the cases worth testing can actually be tested.
+ *
+ *
Without it, every test of this controller would need a Graph credential and a live Entra
+ * tenant -- which means the interesting ones (a tenant-owner reaching into another tenant, a
+ * password reset aimed at a Global Administrator, the whole thing being down) would be exactly
+ * the ones nobody could write.
+ *
+ *
{@link #unavailable} makes it throw the way Graph does when it is throttling or down, which
+ * is the difference between "this tenant has no teams" and "we could not ask" -- a distinction
+ * the controller is supposed to keep and this fake exists to check.
+ */
+class InMemoryDirectory implements Directory {
+
+ private final Map> teams = new LinkedHashMap<>();
+ private final Map> members = new LinkedHashMap<>();
+ private final Map users = new LinkedHashMap<>();
+ private final List resetUserIds = new ArrayList<>();
+ private boolean unavailable;
+
+ /** Makes every operation fail the way an unreachable or throttling directory does. */
+ void unavailable() {
+ this.unavailable = true;
+ }
+
+ void putTeam(String groupId, DirectoryTeam team) {
+ teams.computeIfAbsent(groupId, key -> new ArrayList<>()).add(team);
+ }
+
+ void putMember(String groupId, DirectoryUser user) {
+ members.computeIfAbsent(groupId, key -> new ArrayList<>()).add(user);
+ users.put(user.id(), user);
+ }
+
+ /** The ids whose password was actually reset -- so a test can assert one was *not*. */
+ List resetUserIds() {
+ return List.copyOf(resetUserIds);
+ }
+
+ private void check() {
+ if (unavailable) {
+ throw new DirectoryUnavailableException("the directory is unavailable");
+ }
+ }
+
+ @Override
+ public List teamsIn(String groupId) {
+ check();
+ return List.copyOf(teams.getOrDefault(groupId, List.of()));
+ }
+
+ @Override
+ public List membersOf(String groupId) {
+ check();
+ return List.copyOf(members.getOrDefault(groupId, List.of()));
+ }
+
+ @Override
+ public DirectoryTeam createTeam(String groupId, String displayName) {
+ check();
+ DirectoryTeam team = new DirectoryTeam("team-" + displayName.toLowerCase(java.util.Locale.ROOT), displayName, 0);
+ putTeam(groupId, team);
+ return team;
+ }
+
+ @Override
+ public DirectoryUser invite(String groupId, String email, String displayName) {
+ check();
+ DirectoryUser invited = DirectoryUser.member("user-" + email, displayName, email);
+ putMember(groupId, invited);
+ return invited;
+ }
+
+ @Override
+ public DirectoryUser findUser(String userId) {
+ check();
+ return users.get(userId);
+ }
+
+ @Override
+ public String resetPassword(String userId) {
+ check();
+ resetUserIds.add(userId);
+ return "temporary-password";
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryControllerTest.java
new file mode 100644
index 0000000..4f123b4
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryControllerTest.java
@@ -0,0 +1,290 @@
+/**
+ * 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.directory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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 io.micronaut.security.authentication.Authentication;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import net.onelitefeather.apus.api.directory.DirectoryGuard;
+import net.onelitefeather.apus.api.directory.DirectoryTeam;
+import net.onelitefeather.apus.api.directory.DirectoryUser;
+import net.onelitefeather.apus.api.directory.TenantGroupIndex;
+import net.onelitefeather.apus.api.rest.directory.DirectoryRequests.CreateTeamRequest;
+import net.onelitefeather.apus.api.rest.directory.DirectoryRequests.InviteUserRequest;
+import net.onelitefeather.apus.api.rest.support.BadRequestException;
+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.support.PrincipalResolver;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TenantDirectoryControllerTest {
+
+ private static final String ACME_GROUP = "g-acme";
+ private static final String GLOBEX_GROUP = "g-globex";
+
+ private final InMemoryTenantRepository tenants = new InMemoryTenantRepository();
+ private final InMemoryDirectory directory = new InMemoryDirectory();
+ private final DirectoryGuard guard = new DirectoryGuard();
+ private final PrincipalResolver principals = new PrincipalResolver();
+ private final TenantDirectoryController controller =
+ new TenantDirectoryController(tenants, directory, guard, principals);
+
+ @BeforeEach
+ void setUp() {
+ tenants.put(tenant("acme", ACME_GROUP));
+ tenants.put(tenant("globex", GLOBEX_GROUP));
+ tenants.put(tenant("unconfigured", null));
+ TenantGroupIndex index = TenantGroupIndex.of(tenants.list());
+ principals.setGroupIndex(index);
+ guard.setManagedGroups(index.managedGroups());
+ }
+
+ private static Tenant tenant(String name, String groupId) {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName(name);
+ tenant.getSpec().getIdentity().setGroupId(groupId);
+ return tenant;
+ }
+
+ private static Authentication platformAdmin() {
+ return Authentication.build("root", List.of("platform-admin"), Map.of());
+ }
+
+ /** A tenant-owner whose tenant comes from the groups claim, exactly as in production. */
+ private static Authentication owner(String subject, String group) {
+ return Authentication.build(subject, List.of("tenant-owner"), Map.of("groups", List.of(group)));
+ }
+
+ private static Authentication viewer(String group) {
+ return Authentication.build("bob", List.of("tenant-viewer"), Map.of("groups", List.of(group)));
+ }
+
+ // --- reading -------------------------------------------------------------------------------
+
+ @Test
+ void showsTheTeamsAndMembersOfATenant() {
+ directory.putTeam(ACME_GROUP, new DirectoryTeam("t1", "Builders", 3));
+ directory.putMember(ACME_GROUP, DirectoryUser.member("u1", "Alice", "alice@acme.example"));
+
+ var body = controller.read(owner("alice", ACME_GROUP), "acme").body();
+
+ assertEquals(1, body.teams().size());
+ assertEquals("Builders", body.teams().get(0).displayName());
+ assertEquals(1, body.users().size());
+ assertNull(body.unavailableReason());
+ }
+
+ @Test
+ void countsTeamsAndUsers() {
+ directory.putTeam(ACME_GROUP, new DirectoryTeam("t1", "Builders", 3));
+ directory.putMember(ACME_GROUP, DirectoryUser.member("u1", "Alice", "alice@acme.example"));
+ directory.putMember(ACME_GROUP, DirectoryUser.member("u2", "Carol", "carol@acme.example"));
+
+ var counts = controller.counts(platformAdmin(), "acme").body();
+
+ assertEquals(1, counts.teams());
+ assertEquals(2, counts.users());
+ assertNull(counts.unavailableReason());
+ }
+
+ @Test
+ void reportsCountsAsUnavailableRatherThanZeroWhenTheDirectoryIsDown() {
+ // A zero here would read as "this tenant has nobody in it", which is something an
+ // administrator would act on. The page around it must keep working either way.
+ directory.unavailable();
+
+ var counts = controller.counts(platformAdmin(), "acme").body();
+
+ assertNull(counts.teams());
+ assertNull(counts.users());
+ assertNotNull(counts.unavailableReason());
+ }
+
+ @Test
+ void aDownDirectoryDoesNotFailTheTenantPage() {
+ directory.unavailable();
+
+ var body = controller.read(platformAdmin(), "acme").body();
+
+ assertTrue(body.teams().isEmpty());
+ assertNotNull(body.unavailableReason());
+ }
+
+ // --- who may look at what -------------------------------------------------------------------
+
+ @Test
+ void refusesATenantOwnerLookingIntoAnotherTenant() {
+ assertThrows(ForbiddenException.class, () -> controller.read(owner("alice", ACME_GROUP), "globex"));
+ }
+
+ @Test
+ void refusesEveryoneOnATenantWithNoIdentityGroup() {
+ // Not "anything goes": an unconfigured tenant is the narrowest, not the widest.
+ assertThrows(ForbiddenException.class, () -> controller.read(platformAdmin(), "unconfigured"));
+ }
+
+ @Test
+ void reportsAnUnknownTenantAsNotFound() {
+ assertThrows(NotFoundException.class, () -> controller.read(platformAdmin(), "does-not-exist"));
+ }
+
+ // --- assignments ------------------------------------------------------------------------------
+
+ @Test
+ void showsWhoIsInATeam() {
+ directory.putTeam(ACME_GROUP, new DirectoryTeam("t1", "Builders", 2));
+ directory.putMember("t1", DirectoryUser.member("u1", "Alice", "alice@acme.example"));
+ directory.putMember("t1", DirectoryUser.member("u2", "Carol", "carol@acme.example"));
+
+ var members = controller.teamMembers(owner("alice", ACME_GROUP), "acme", "t1").body();
+
+ assertEquals(2, members.size());
+ assertEquals("Alice", members.get(0).displayName());
+ }
+
+ @Test
+ void refusesToReadTheMembershipOfAGroupThatIsNotThisTenantsTeam() {
+ // Without checking that the team belongs to this tenant, the group guard would pass --
+ // the tenant's own group is managed -- while the id in the path pointed anywhere in the
+ // directory, which would make this a way to read every group's membership.
+ directory.putTeam(GLOBEX_GROUP, new DirectoryTeam("t-globex", "Their team", 1));
+ directory.putMember("t-globex", DirectoryUser.member("u-dana", "Dana", "dana@globex.example"));
+
+ assertThrows(
+ NotFoundException.class,
+ () -> controller.teamMembers(owner("alice", ACME_GROUP), "acme", "t-globex"));
+ }
+
+ // --- creating and inviting -------------------------------------------------------------------
+
+ @Test
+ void createsATeam() {
+ var response = controller.createTeam(owner("alice", ACME_GROUP), "acme", new CreateTeamRequest("Builders"));
+
+ assertEquals(201, response.getStatus().getCode());
+ assertEquals("Builders", response.body().displayName());
+ assertEquals(1, directory.teamsIn(ACME_GROUP).size());
+ }
+
+ @Test
+ void refusesAViewerCreatingATeam() {
+ assertThrows(
+ ForbiddenException.class,
+ () -> controller.createTeam(viewer(ACME_GROUP), "acme", new CreateTeamRequest("Builders")));
+ }
+
+ @Test
+ void refusesATeamWithNoName() {
+ assertThrows(
+ BadRequestException.class,
+ () -> controller.createTeam(platformAdmin(), "acme", new CreateTeamRequest(" ")));
+ }
+
+ @Test
+ void invitesSomebody() {
+ var response = controller.invite(
+ platformAdmin(), "acme", new InviteUserRequest("carol@acme.example", "Carol"));
+
+ assertEquals(201, response.getStatus().getCode());
+ assertEquals("carol@acme.example", response.body().email());
+ }
+
+ @Test
+ void namesAnInviteeAfterTheirAddressWhenNoNameIsGiven() {
+ // Better than a blank row in every list from then on.
+ var response = controller.invite(platformAdmin(), "acme", new InviteUserRequest("carol@acme.example", null));
+
+ assertEquals("carol", response.body().displayName());
+ }
+
+ @Test
+ void refusesAnInvitationWithoutAUsableAddress() {
+ assertThrows(
+ BadRequestException.class,
+ () -> controller.invite(platformAdmin(), "acme", new InviteUserRequest("not-an-address", null)));
+ assertThrows(
+ BadRequestException.class,
+ () -> controller.invite(platformAdmin(), "acme", new InviteUserRequest("", null)));
+ }
+
+ // --- password reset, where the damage is worst -----------------------------------------------
+
+ @Test
+ void resetsAnOrdinaryMembersPassword() {
+ directory.putMember(ACME_GROUP, DirectoryUser.member("u1", "Alice", "alice@acme.example"));
+
+ var response = controller.resetPassword(platformAdmin(), "acme", "u1");
+
+ assertEquals("u1", response.body().userId());
+ assertNotNull(response.body().temporaryPassword());
+ assertEquals(List.of("u1"), directory.resetUserIds());
+ }
+
+ @Test
+ void refusesToResetTheePasswordOfAPrivilegedAccount() {
+ // The escalation this whole subsystem is shaped around: a tenant-owner taking over a
+ // directory administrator through a console button.
+ directory.putMember(
+ ACME_GROUP,
+ new DirectoryUser("u-admin", "Root", "root@acme.example", Set.of("Global Administrator")));
+
+ assertThrows(
+ ForbiddenException.class,
+ () -> controller.resetPassword(owner("alice", ACME_GROUP), "acme", "u-admin"));
+ assertTrue(directory.resetUserIds().isEmpty(), "nothing may have been reset");
+ }
+
+ @Test
+ void refusesToResetSomebodyWhoIsNotAMemberOfThisTenant() {
+ // 404 rather than 403: whether an account exists elsewhere in the directory is not
+ // something this endpoint should confirm.
+ directory.putMember(GLOBEX_GROUP, DirectoryUser.member("u-globex", "Dana", "dana@globex.example"));
+
+ assertThrows(
+ NotFoundException.class, () -> controller.resetPassword(platformAdmin(), "acme", "u-globex"));
+ assertTrue(directory.resetUserIds().isEmpty());
+ }
+
+ @Test
+ void refusesAViewerResettingAnything() {
+ directory.putMember(ACME_GROUP, DirectoryUser.member("u1", "Alice", "alice@acme.example"));
+
+ assertThrows(ForbiddenException.class, () -> controller.resetPassword(viewer(ACME_GROUP), "acme", "u1"));
+ assertTrue(directory.resetUserIds().isEmpty());
+ }
+
+ @Test
+ void refusesResettingYourOwnPasswordHere() {
+ directory.putMember(ACME_GROUP, DirectoryUser.member("alice", "Alice", "alice@acme.example"));
+
+ assertThrows(
+ ForbiddenException.class,
+ () -> controller.resetPassword(owner("alice", ACME_GROUP), "acme", "alice"));
+ assertTrue(directory.resetUserIds().isEmpty());
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationFilterOrderTest.java b/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationFilterOrderTest.java
new file mode 100644
index 0000000..d1c9d94
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationFilterOrderTest.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.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.micronaut.http.filter.ServerFilterPhase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The filter must run after Micronaut's security filter, because it reads the {@code
+ * Authentication} that filter puts on the request. Ordered ahead of it, it would find none and
+ * refuse every impersonated request -- a failure that looks like a permission problem and sends
+ * somebody looking at roles.
+ *
+ *
The order has to be a literal, because an annotation value must be a constant expression and
+ * {@code ServerFilterPhase.SECURITY.after()} is a method call. This test is what keeps the
+ * literal honest: a Micronaut release that renumbers the phases fails here rather than silently
+ * reordering the filter.
+ */
+class ImpersonationFilterOrderTest {
+
+ @Test
+ void runsImmediatelyAfterTheSecurityFilter() {
+ assertEquals(ServerFilterPhase.SECURITY.after(), ImpersonationFilter.ORDER);
+ }
+
+ @Test
+ void andThereforeAfterSecurityItself() {
+ // Stated separately from the equality above: if the phase numbering ever changed such
+ // that `after()` no longer sorted after `order()`, the first test could still pass while
+ // the filter ran too early.
+ assertTrue(
+ ImpersonationFilter.ORDER > ServerFilterPhase.SECURITY.order(),
+ "impersonation must be applied once the caller is authenticated, never before");
+ }
+}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationPolicyTest.java b/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationPolicyTest.java
new file mode 100644
index 0000000..109e71c
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationPolicyTest.java
@@ -0,0 +1,119 @@
+/**
+ * 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Impersonation is a feature whose failure mode is somebody quietly acting with authority that
+ * is not theirs, so these tests are almost entirely about what it refuses and what it strips.
+ */
+class ImpersonationPolicyTest {
+
+ private final ImpersonationPolicy policy = new ImpersonationPolicy();
+
+ private static ApusPrincipal platformAdmin() {
+ return new ApusPrincipal("root", null, Set.of(Role.PLATFORM_ADMIN));
+ }
+
+ private static ApusPrincipal owner(String tenant) {
+ return new ApusPrincipal("alice", tenant, Set.of(Role.TENANT_OWNER));
+ }
+
+ private static ApusPrincipal viewer(String tenant) {
+ return new ApusPrincipal("bob", tenant, Set.of(Role.TENANT_VIEWER));
+ }
+
+ @Test
+ void aPlatformAdminCanActWithinAnyTenant() {
+ Impersonation impersonation = policy.resolve(platformAdmin(), "acme", "u-alice");
+
+ assertEquals("acme", impersonation.effective().tenant());
+ assertEquals("u-alice", impersonation.effective().subject());
+ }
+
+ @Test
+ void theImpersonatedSessionNeverCarriesThePlatformRole() {
+ // The rule the whole feature rests on. A session that kept platform-admin would let
+ // someone reach every other tenant while wearing a tenant member's name -- the exact
+ // opposite of what an audit trail is for.
+ Impersonation impersonation = policy.resolve(platformAdmin(), "acme", "u-alice");
+
+ assertFalse(impersonation.effective().isPlatformAdmin());
+ assertEquals(Set.of(Role.TENANT_OWNER), impersonation.effective().roles());
+ }
+
+ @Test
+ void theRealSubjectSurvivesForTheAuditTrail() {
+ // "Someone did X" is useless if the someone is the person they were pretending to be.
+ Impersonation impersonation = policy.resolve(platformAdmin(), "acme", "u-alice");
+
+ assertEquals("root", impersonation.realSubject());
+ assertTrue(impersonation.describe().contains("root"));
+ assertTrue(impersonation.describe().contains("acme"));
+ }
+
+ @Test
+ void aTenantOwnerCanActWithinItsOwnTenant() {
+ // The "as org admin" case, and it grants nothing they did not already have there.
+ Impersonation impersonation = policy.resolve(owner("acme"), "acme", "u-carol");
+
+ assertEquals("acme", impersonation.effective().tenant());
+ assertEquals("alice", impersonation.realSubject());
+ }
+
+ @Test
+ void aTenantOwnerCannotActInSomebodyElsesTenant() {
+ assertThrows(ForbiddenException.class, () -> policy.resolve(owner("acme"), "globex", "u-dana"));
+ }
+
+ @Test
+ void aViewerCannotActAsAnybody() {
+ // Impersonation is not a way to gain a role. A viewer has nothing to narrow down from.
+ assertThrows(ForbiddenException.class, () -> policy.resolve(viewer("acme"), "acme", "u-carol"));
+ }
+
+ @Test
+ void aPrincipalWithNoTenantAndNoPlatformRoleCannotActAnywhere() {
+ ApusPrincipal stranger = new ApusPrincipal("nobody", null, Set.of(Role.TENANT_OWNER));
+
+ assertThrows(ForbiddenException.class, () -> policy.resolve(stranger, "acme", null));
+ }
+
+ @Test
+ void actingAsTheTenantItselfKeepsYourOwnName() {
+ // "As org admin" rather than as a named person: nothing is gained by inventing a subject,
+ // and the audit trail is clearer when the name is the real one.
+ Impersonation impersonation = policy.resolve(platformAdmin(), "acme", null);
+
+ assertEquals("root", impersonation.effective().subject());
+ assertEquals("acme", impersonation.effective().tenant());
+ }
+
+ @Test
+ void refusesWithoutATenantToActIn() {
+ assertThrows(ForbiddenException.class, () -> policy.resolve(platformAdmin(), null, "u-alice"));
+ assertThrows(ForbiddenException.class, () -> policy.resolve(platformAdmin(), " ", "u-alice"));
+ }
+}
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
index fcec2aa..52a72f4 100644
--- a/api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java
+++ b/api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java
@@ -24,6 +24,7 @@
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.EnumSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
@@ -62,4 +63,16 @@ void fromClaimTrimsSurroundingWhitespace() {
void fromClaimRejectsNullBlankAndEmpty(String claim) {
assertTrue(Role.fromClaim(claim).isEmpty());
}
+
+ /**
+ * Every role must survive a round trip, for every role there is. Impersonation builds an
+ * authentication of its own and spells roles with {@code claimValue()}; if one of them did
+ * not read back, that session would silently hold fewer roles than it was granted -- and the
+ * failure would look like a permission problem, not like a spelling one.
+ */
+ @ParameterizedTest
+ @EnumSource(Role.class)
+ void claimValueIsTheExactInverseOfFromClaim(Role role) {
+ assertEquals(Optional.of(role), Role.fromClaim(role.claimValue()));
+ }
}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverGroupsTest.java b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverGroupsTest.java
new file mode 100644
index 0000000..ac3983f
--- /dev/null
+++ b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverGroupsTest.java
@@ -0,0 +1,108 @@
+/**
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import io.micronaut.security.authentication.Authentication;
+import java.util.List;
+import java.util.Map;
+import net.onelitefeather.apus.api.directory.TenantGroupIndex;
+import net.onelitefeather.apus.api.security.ApusPrincipal;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Resolving a tenant from the {@code groups} claim.
+ *
+ *
This is the fix for a bug that made the tenant application show "No tenant to show" for
+ * every user since single sign-on was set up: the resolver read a claim named {@code
+ * organization} which the app registration never emitted, because neither {@code
+ * groupMembershipClaims} nor {@code optionalClaims} was ever configured on it. The claim was not
+ * mis-mapped -- it did not exist.
+ */
+class PrincipalResolverGroupsTest {
+
+ private static Tenant tenant(String name, String groupId) {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName(name);
+ tenant.getSpec().getIdentity().setGroupId(groupId);
+ return tenant;
+ }
+
+ private static PrincipalResolver resolverFor(Tenant... tenants) {
+ PrincipalResolver resolver = new PrincipalResolver();
+ resolver.setGroupIndex(TenantGroupIndex.of(List.of(tenants)));
+ return resolver;
+ }
+
+ private static Authentication withGroups(Object groups) {
+ return Authentication.build("alice", List.of("tenant-owner"), Map.of("groups", groups));
+ }
+
+ @Test
+ void resolvesTheTenantFromTheGroupsClaim() {
+ ApusPrincipal principal = resolverFor(tenant("acme", "g-acme")).resolve(withGroups(List.of("g-acme")));
+
+ assertEquals("acme", principal.tenant());
+ }
+
+ @Test
+ void stillPrefersAnExplicitOrganizationClaimWhenOneIsPresent() {
+ // Some brokers do emit it, and a platform that configured one should not have its
+ // meaning quietly overridden by group membership.
+ Authentication authentication = Authentication.build(
+ "alice", List.of("tenant-owner"), Map.of("organization", "globex", "groups", List.of("g-acme")));
+
+ assertEquals(
+ "globex", resolverFor(tenant("acme", "g-acme")).resolve(authentication).tenant());
+ }
+
+ @Test
+ void hasNoTenantWhenNoGroupMatches() {
+ // The failure mode does not change -- only the success case starts working.
+ assertNull(resolverFor(tenant("acme", "g-acme"))
+ .resolve(withGroups(List.of("g-unrelated")))
+ .tenant());
+ }
+
+ @Test
+ void hasNoTenantWhenTheTokenCarriesNoGroupsAtAll() {
+ Authentication bare = Authentication.build("alice", List.of("tenant-owner"), Map.of());
+
+ assertNull(resolverFor(tenant("acme", "g-acme")).resolve(bare).tenant());
+ }
+
+ @Test
+ void ignoresAGroupsClaimThatIsNotAListOfStrings() {
+ // A claim of an unexpected shape must not throw on every request; it must simply fail to
+ // identify a tenant, which is the same outcome as having no groups.
+ assertNull(resolverFor(tenant("acme", "g-acme")).resolve(withGroups("g-acme")).tenant());
+ assertNull(resolverFor(tenant("acme", "g-acme"))
+ .resolve(withGroups(List.of(1, 2, 3)))
+ .tenant());
+ }
+
+ @Test
+ void resolvesNothingBeforeTheIndexHasBeenLoaded() {
+ // The state the resolver is in for the first moments after startup. Failing to identify a
+ // tenant is right here; inventing one would not be.
+ assertNull(new PrincipalResolver().resolve(withGroups(List.of("g-acme"))).tenant());
+ }
+}
diff --git a/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
index 3f888a2..929dd09 100644
--- a/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
+++ b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
@@ -30,6 +30,11 @@ spec:
type: string
type: array
type: object
+ identity:
+ properties:
+ groupId:
+ type: string
+ type: object
policy:
items:
properties:
diff --git a/deploy/charts/apus-platform/templates/api-deployment.yaml b/deploy/charts/apus-platform/templates/api-deployment.yaml
index 2a18823..e4dfd8f 100644
--- a/deploy/charts/apus-platform/templates/api-deployment.yaml
+++ b/deploy/charts/apus-platform/templates/api-deployment.yaml
@@ -49,6 +49,23 @@ spec:
value: {{ .Values.auth.issuer | quote }}
- name: APUS_JWT_JWKS_URI
value: {{ .Values.auth.jwksUri | quote }}
+ {{- if .Values.directory.enabled }}
+ # Teams, invitations and password resets. A *second*, confidential app registration
+ # -- never the SPA's: a public client cannot hold a secret or use client credentials
+ # at all, so Graph application permissions there would be unusable and would suggest
+ # the browser held them.
+ - name: APUS_DIRECTORY_TENANT_ID
+ value: {{ .Values.directory.tenantId | quote }}
+ - name: APUS_DIRECTORY_CLIENT_ID
+ value: {{ .Values.directory.clientId | quote }}
+ # From a Secret via secretKeyRef, never inlined: a Deployment manifest is readable by
+ # anything allowed to read Deployments in this namespace.
+ - name: APUS_DIRECTORY_CLIENT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.directory.clientSecret.secretName | quote }}
+ key: {{ .Values.directory.clientSecret.key | quote }}
+ {{- end }}
{{- if .Values.otel.endpoint }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: {{ .Values.otel.endpoint | quote }}
diff --git a/deploy/charts/apus-platform/values.yaml b/deploy/charts/apus-platform/values.yaml
index 61d082f..a22d9f1 100644
--- a/deploy/charts/apus-platform/values.yaml
+++ b/deploy/charts/apus-platform/values.yaml
@@ -13,6 +13,31 @@ auth:
# next task has somewhere to point once it lands.
audience: apus
+# Teams, invitations and password resets, through Microsoft Graph.
+#
+# Off by default, and the API says so rather than failing to start: this needs Graph application
+# permissions with admin consent -- Group.ReadWrite.All, User.ReadWrite.All, User.Invite.All --
+# which a platform may reasonably decline to grant. Without them the console shows those panels
+# as unavailable and everything else works unchanged.
+#
+# Those permissions are directory-wide; Entra has no "these groups only" variant. What narrows
+# them is Apus's own DirectoryGuard, which refuses any group no Tenant claims via
+# spec.identity.groupId, any user outside one, and any password reset aimed at a privileged
+# directory account.
+#
+# The registration named here MUST NOT be the one the browser uses. That one is a SPA -- a public
+# client, which cannot hold a secret and cannot use the client-credentials flow at all.
+directory:
+ enabled: false
+ # The Entra tenant (directory) id.
+ tenantId: ""
+ # The confidential app registration's client id.
+ clientId: ""
+ clientSecret:
+ # A Secret you create; this chart never templates the value itself.
+ secretName: apus-directory-credentials
+ key: client-secret
+
api:
image:
repository: harbor.onelitefeather.dev/apus/api
diff --git a/docs/runbooks/directory-and-impersonation-setup.md b/docs/runbooks/directory-and-impersonation-setup.md
new file mode 100644
index 0000000..8c903ea
--- /dev/null
+++ b/docs/runbooks/directory-and-impersonation-setup.md
@@ -0,0 +1,194 @@
+# Enabling teams, users and impersonation
+
+What has to happen in Microsoft Entra and in the cluster before the console's teams, invitations,
+password resets and impersonation do anything. Everything here is a one-time setup per platform,
+except step 4 which is once per tenant.
+
+None of it can be automated from inside Apus: granting an application permission is a directory
+administrator's decision, and Apus deliberately holds no permission that would let it grant
+itself another.
+
+## 0. Before you start
+
+You need a directory administrator who can grant admin consent, and the Azure CLI signed in to
+the right tenant:
+
+```bash
+az login --tenant 1a14dfb5-0eac-41bf-94cb-195c2e387520
+az account show --query tenantId -o tsv
+```
+
+## 1. Emit the `groups` claim
+
+**This step alone fixes the "No tenant to show" bug**, and it is worth doing even if you decide
+against everything below.
+
+The tenant application has shown *"No tenant (platform-level account)"* for every user since
+single sign-on was set up. The cause is not a mis-mapped claim: the `Apus` app registration has
+`groupMembershipClaims: null`, so the `organization` claim the API reads was never emitted at all.
+
+```bash
+APP_OBJECT_ID=$(az ad app show --id 59a9ea74-a98c-4b6b-b60b-3a309128a1cb --query id -o tsv)
+
+az rest --method PATCH \
+ --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
+ --headers "Content-Type=application/json" \
+ --body '{"groupMembershipClaims":"SecurityGroup"}'
+```
+
+Verify:
+
+```bash
+az ad app show --id 59a9ea74-a98c-4b6b-b60b-3a309128a1cb --query groupMembershipClaims -o tsv
+# SecurityGroup
+```
+
+Existing sessions keep their old tokens. Sign out and back in to get a token carrying `groups`.
+
+**Above roughly 200 group memberships Entra stops sending the list** and sends a `_claim_names`
+pointer instead. `PrincipalResolver` treats that as "no tenant" rather than guessing — the same
+outcome as today, so nothing regresses, but such a user will not resolve to a tenant.
+
+## 2. A second app registration for directory access
+
+**Do not add Graph permissions to the `Apus` registration.** It is a SPA — a public client. It
+cannot hold a secret and cannot use the client-credentials flow at all, so application
+permissions there would be unusable, and having them listed invites the belief that the browser
+holds them.
+
+```bash
+az ad app create --display-name "Apus Directory" --sign-in-audience AzureADMyOrg
+DIR_APP_ID=$(az ad app list --display-name "Apus Directory" --query "[0].appId" -o tsv)
+az ad sp create --id "$DIR_APP_ID"
+```
+
+Add the permissions. These are **application** permissions, not delegated:
+
+```bash
+GRAPH=00000003-0000-0000-c000-000000000000
+
+# Group.ReadWrite.All 62a82d76-70ea-41e2-9197-370581804d09 teams
+# User.Invite.All 09850681-111b-4a89-9bed-3f2cae46d706 invitations
+# User.Read.All df021288-bdef-4463-88db-98f22de89214 people, assignments
+# User-PasswordProfile.ReadWrite.All cc117bb9-00cf-4eb8-b580-ea2a878fe8f7 password reset
+for ID in 62a82d76-70ea-41e2-9197-370581804d09 \
+ 09850681-111b-4a89-9bed-3f2cae46d706 \
+ df021288-bdef-4463-88db-98f22de89214 \
+ cc117bb9-00cf-4eb8-b580-ea2a878fe8f7; do
+ az ad app permission add --id "$DIR_APP_ID" --api "$GRAPH" --api-permissions "$ID=Role"
+done
+
+az ad app permission admin-consent --id "$DIR_APP_ID"
+```
+
+**`User.ReadWrite.All` is deliberately not in that list, and `User-PasswordProfile.ReadWrite.All`
+is.** Microsoft split password resets out of `User.ReadWrite.All` into a permission of their own,
+so the broad one would not actually let Apus reset a password — and asking for it anyway would
+grant the ability to rewrite every attribute of every account for nothing in return. All four ids
+above were read back from the Graph service principal in this tenant rather than copied from
+memory:
+
+```bash
+az ad sp show --id 00000003-0000-0000-c000-000000000000 \
+ --query "appRoles[?value=='Group.ReadWrite.All' || value=='User.Invite.All' \
+ || value=='User.Read.All' || value=='User-PasswordProfile.ReadWrite.All'].{v:value,id:id}" \
+ -o table
+```
+
+**What you are granting, plainly.** These are directory-wide, and Entra offers no narrower
+variant: the holder can rename any group in the organisation and reset the password of any
+account in it, including accounts that have nothing to do with Apus.
+
+What narrows them is Apus's own `DirectoryGuard`, and it is worth knowing exactly what it
+promises, because nothing else does:
+
+- any group no `Tenant` claims via `spec.identity.groupId` is refused
+- any user who is not a member of such a group is refused
+- any password reset aimed at an account holding a privileged directory role
+ (Global Administrator, User Administrator, and a deliberately generous list of others) is
+ refused
+- resetting your own password here is refused — that belongs at the identity provider
+- every mutation is logged with the acting principal, before the call, so failed attempts are
+ recorded too
+
+If that is not a trade you want to make, stop here. Step 1 stands on its own, and the console
+will simply show those panels as unavailable.
+
+## 3. The client secret
+
+Workload identity federation would be better — nothing to rotate, nothing to leak — but it is not
+available on this cluster: the API server's OIDC issuer is `https://api.k8s.onelite.feather:6443`,
+an internal name on a private address that Entra cannot reach to fetch signing keys.
+
+```bash
+az ad app credential reset --id "$DIR_APP_ID" --display-name apus-directory --years 1
+# note the `password` field -- it is shown once
+```
+
+Put it in the cluster, in the namespace the API runs in:
+
+```bash
+kubectl create secret generic apus-directory-credentials \
+ --namespace apus-system \
+ --from-literal=client-secret=''
+```
+
+Then turn the feature on in the `apus-platform` HelmRelease:
+
+```yaml
+directory:
+ enabled: true
+ tenantId: 1a14dfb5-0eac-41bf-94cb-195c2e387520
+ clientId:
+ clientSecret:
+ secretName: apus-directory-credentials
+ key: client-secret
+```
+
+**Record the expiry.** A year from now this stops working, and the failure looks like the
+directory being down rather than like a credential having lapsed.
+
+## 4. Per tenant: point the tenant at its group
+
+Once per tenant, and nothing works for that tenant until it is done — neither membership
+resolution nor any directory operation, because a tenant with no group is refused rather than
+treated as "any group".
+
+```bash
+kubectl patch tenant onelitefeather-dev --type merge \
+ -p '{"spec":{"identity":{"groupId":""}}}'
+```
+
+Find the group id:
+
+```bash
+az ad group list --display-name "Apus Tenant onelitefeather-dev" --query "[0].id" -o tsv
+```
+
+If no such group exists yet, create one and put the tenant's people in it:
+
+```bash
+az ad group create \
+ --display-name "Apus Tenant onelitefeather-dev" \
+ --mail-nickname apus-onelitefeather-dev
+```
+
+## 5. Check it
+
+```bash
+# The API recognises the group
+kubectl logs -n apus-system deploy/apus-platform-api | grep -i "tenant group index"
+
+# A signed-in user now resolves to a tenant: the tenant app's /account page should show
+# the tenant name instead of "No tenant (platform-level account)".
+```
+
+In the console, a tenant's page gains a "Teams and people" section. If it says the directory
+could not be reached, the message names which of the three settings in step 3 is missing.
+
+## What impersonation needs
+
+Nothing beyond the above. It is two request headers applied by `ImpersonationFilter`, and it
+grants nothing: the effective principal never holds `platform-admin` and never holds a role its
+caller does not, so anyone using it can only ever do less than they could as themselves — in a
+different tenant. Every impersonated request is logged under the real subject.
diff --git a/docs/superpowers/specs/2026-08-16-teams-and-users-design.md b/docs/superpowers/specs/2026-08-16-teams-and-users-design.md
new file mode 100644
index 0000000..6e8d874
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-teams-and-users-design.md
@@ -0,0 +1,166 @@
+# Apus — Teams and Users through Entra: Design
+
+**As of:** 2026-08-16
+**Status:** Draft for approval
+
+The console shows how many teams and users a tenant has, creates teams, invites users, resets
+passwords, and shows assignments. This is the third of four subsystems; impersonation is the
+fourth and depends on this one.
+
+## 0. Two problems that look like one
+
+They are not one problem, and conflating them would put a directory-wide credential in a browser.
+
+**Reading who someone is** — which tenant does this signed-in user belong to? That is a token
+question. It needs no Microsoft Graph, no secret and no new app registration.
+
+**Changing the directory** — create a group, invite a person, reset a password. That needs
+Microsoft Graph application permissions, which can only live on a *confidential* client.
+
+The first also fixes a bug that has been open since SSO was set up.
+
+## 1. Tenant membership, and the "No tenant to show" bug
+
+The tenant application has shown *"No tenant (platform-level account)"* for every user since
+Entra was wired up. The cause is now confirmed rather than suspected: `PrincipalResolver` reads
+the claim `organization`, and on the `Apus` app registration both `groupMembershipClaims` and
+`optionalClaims` are `null`. Entra was never going to emit that claim. It is not a mapping that
+broke — it is a claim that was never configured to exist.
+
+**Fix: map an Entra group to a tenant.**
+
+```yaml
+spec:
+ identity:
+ # The Entra group whose members belong to this tenant.
+ groupId: 8f14e45f-ceea-467a-9a3f-3a1f9c0e2b77
+```
+
+`groupMembershipClaims: "SecurityGroup"` is set on the app registration so tokens carry a
+`groups` claim, and `PrincipalResolver` resolves the tenant by looking each group id up against
+the `Tenant` resources. A user in no mapped group has no tenant, exactly as today — the failure
+mode does not change, only the success case starts working.
+
+**The `groups` claim overflows at ~200 group memberships**, at which point Entra emits a
+`_claim_names`/`_claim_sources` pointer instead of the list and the token carries no groups at
+all. Handled explicitly: the resolver treats an overflowed token as "tenant unknown" and says so
+in the log, rather than silently behaving like a user with no groups. Resolving an overflowed
+token properly needs a Graph call, which belongs to §2 and is not required for this to work at
+the sizes this platform has.
+
+**`Tenant.spec.identity.groupId` is optional.** A platform that does not use group-based
+membership keeps exactly today's behaviour.
+
+## 2. Changing the directory: a second app registration
+
+**The Graph permissions must not go on the `Apus` app registration**, and this is not a
+preference. `Apus` is a SPA — a public client. A public client cannot hold a secret and cannot
+use the client-credentials flow at all, so application permissions granted there would never be
+usable. Worse, it invites the assumption that the browser holds them: anything the console can
+do, the person operating the console's browser can do by hand.
+
+So a second registration, `Apus Directory`, confidential, used only by the `api` module,
+server-side. The console never speaks to Graph; it calls the Apus API, which is already
+role-gated.
+
+| Permission | For | Type |
+| --- | --- | --- |
+| `Group.ReadWrite.All` | create a team, list teams, read membership | Application |
+| `User.Invite.All` | invite a user | Application |
+| `User.Read.All` | show users and assignments | Application |
+| `User-PasswordProfile.ReadWrite.All` | reset a password | Application |
+
+**Not `User.ReadWrite.All`.** Microsoft split password resets out of it into a permission of
+their own, so the broad one would not actually let Apus reset a password — while granting the
+ability to rewrite every attribute of every account in the directory. Asking for it would be
+strictly more power for strictly less capability.
+
+All require admin consent. Granted deliberately, and the cost is stated in §3 rather than
+buried.
+
+**Credential: a client secret, not workload identity federation.** Federation would be the better
+answer — nothing to rotate, nothing to leak — but it is unavailable here, and this was checked
+rather than assumed: the cluster's OIDC issuer is `https://api.k8s.onelite.feather:6443`, an
+internal name on a private address that Entra cannot reach to fetch keys. So a client secret,
+held in a Kubernetes `Secret`, referenced by the `api` Deployment through `secretKeyRef` and
+never inlined into a manifest, with an expiry date recorded in the runbook.
+
+## 3. What these permissions actually allow, and what stops it
+
+`Group.ReadWrite.All` and `User.ReadWrite.All` are directory-wide. They do not stop at Apus. With
+them the API could rename any group in the OneLiteFeather tenant and reset the password of any
+account in it, including accounts that have nothing to do with this platform. There is no Graph
+scoping that narrows them — Entra has no "these groups only" variant of `Group.ReadWrite.All`.
+
+The narrowing therefore has to be in Apus's own code, and being the only thing standing between
+an API bug and the whole directory, it is written as a guard the operations call rather than a
+check each one remembers:
+
+- **Every group operation is refused unless the group id appears in some `Tenant`'s
+ `spec.identity.groupId`.** A group nobody claims is not Apus's to touch.
+- **Every user operation is refused unless that user is a member of such a group.**
+- **Password reset additionally refuses any user holding a privileged directory role**
+ (Global Administrator, Privileged Role Administrator, User Administrator, and the rest of the
+ documented set). An Apus tenant-owner must not be able to take over a directory admin.
+- **Password reset is refused on the acting user's own account**, which is what a self-service
+ password change is for and is not what this permission is granted for.
+- **Every mutation is audit-logged** with the acting principal, the target, and the outcome —
+ before the call, so an attempt that fails is recorded too.
+
+Each of those is a test, and the tests are written from the attacker's side: "a tenant-owner
+cannot reset a Global Administrator's password" rather than "reset works".
+
+## 4. What the console gets
+
+| Where | What |
+| --- | --- |
+| Tenant list | team and user counts per tenant |
+| Tenant detail | the teams in this tenant, and each team's members |
+| Tenant detail | create a team, invite a user by e-mail |
+| User detail | reset password, showing the temporary password once and never again |
+
+Counts come from Graph and are cached briefly — a tenant list that makes two Graph calls per row
+would be both slow and a good way to meet Graph's throttling.
+
+## 5. Endpoints
+
+All under the existing role model: `platform-admin` for anything cross-tenant, `tenant-owner`
+for a tenant's own directory.
+
+All under `/api/tenants/{name}/directory`, so one prefix carries the whole capability and it is
+obvious from a path which requests reach the identity provider at all.
+
+| Method | Path | Who |
+| --- | --- | --- |
+| `GET` | `…/directory/counts` | read: `platform-admin`, or a member of that tenant |
+| `GET` | `…/directory` | read |
+| `GET` | `…/directory/teams/{teamId}/members` | read — the assignment itself |
+| `POST` | `…/directory/teams` | write: `platform-admin` or `tenant-owner` |
+| `POST` | `…/directory/invitations` | write |
+| `POST` | `…/directory/users/{userId}/password-reset` | write, plus §3's checks on the target |
+
+**A team id in a path is checked against this tenant's teams, not trusted.** The group guard
+would pass on its own — the tenant's own group is managed — while the id pointed at any group in
+the directory, which would turn the members endpoint into a way to read every group in the
+organisation.
+
+A tenant-owner naming a tenant that is not theirs gets `404`, not `403` — the same rule the rest
+of the API already follows, so a probe cannot map which tenants exist.
+
+## 6. Graph is somebody else's service, and it will be down
+
+Every Graph call is wrapped so that a failure is reported as a failure of *that panel*, not of the
+tenant page. A tenant whose storage and renders are fine must not become unreadable because
+Microsoft is throttling. Specifically: counts render as "unavailable" rather than zero — a zero
+that means "we could not ask" is a lie an administrator would act on.
+
+Throttling (`429`) is retried once with the `Retry-After` delay and then surfaced.
+
+## 7. Non-goals
+
+- **No sync of Entra groups into Apus.** Entra stays the system of record; Apus reads it.
+- **No user deletion.** Inviting and resetting is what was asked for; deleting an account is a
+ directory operation with no way back, and no button for it belongs in a tenant console.
+- **No nested group resolution.** A group's direct members are its members. Transitive
+ membership would make every count a different, slower question.
+- **Impersonation is subsystem D**, and depends on this.
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java
index 4a91849..c482ea7 100644
--- a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java
@@ -34,6 +34,9 @@ public class TenantSpec {
*/
private List policy = new ArrayList<>();
+ /** How this tenant's members are recognised in the identity provider. See {@link Identity}. */
+ private Identity identity = new Identity();
+
public String getDisplayName() {
return displayName;
}
@@ -71,6 +74,44 @@ public void setPolicy(List policy) {
this.policy = policy == null ? new ArrayList<>() : policy;
}
+ public Identity getIdentity() {
+ return identity;
+ }
+
+ /** Absorbs {@code null}, the same way {@link #setPolicy} does and for the same reason. */
+ public void setIdentity(Identity identity) {
+ this.identity = identity == null ? new Identity() : identity;
+ }
+
+ /**
+ * Ties this tenant to a group in the identity provider, which is how a signed-in user is
+ * recognised as one of its members.
+ *
+ *
Before this existed, {@code PrincipalResolver} in the {@code api} module read a claim
+ * named {@code organization} that the app registration never emitted -- neither {@code
+ * groupMembershipClaims} nor {@code optionalClaims} was configured -- so every user resolved
+ * to "no tenant" and the tenant application had nothing to show anybody. A group id is
+ * something the provider genuinely puts in a token, and it is the same identifier the
+ * directory operations (teams, invitations) are scoped by.
+ *
+ *
Empty is allowed and means what it did before: membership is not derived from groups,
+ * and no directory operation is permitted against this tenant -- rather than "any group",
+ * which would make an unconfigured tenant the widest one on the platform.
+ */
+ public static class Identity {
+
+ /** Object id of the group whose members belong to this tenant. Empty means unconfigured. */
+ private String groupId;
+
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.groupId = groupId == null || groupId.isBlank() ? null : groupId;
+ }
+ }
+
/** Hard storage limit, enforced by Ceph rather than by this operator. */
public static class StorageQuota {
private String quota = "100Gi";
diff --git a/ui/apps/console/app/components/platform/ImpersonationPanel.vue b/ui/apps/console/app/components/platform/ImpersonationPanel.vue
new file mode 100644
index 0000000..7ce201f
--- /dev/null
+++ b/ui/apps/console/app/components/platform/ImpersonationPanel.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+ View as this tenant
+
+
+
+
+
+ You are viewing Apus as
+ {{ actingAs.user ?? 'the tenant itself' }}
+ in {{ actingAs.tenant }}.
+
+
+ Your own platform-wide access is switched off while this lasts, and every request is
+ recorded under your name.
+
+
+
+ Stop and go back to yourself
+
+
+
+
+
+
+ See exactly what {{ tenant }} sees — useful when a
+ report only makes sense from inside the tenant. Every request made this way is
+ recorded under your own name, and platform-wide access is dropped for
+ the duration, so you can never do more this way than you could as yourself.
+
+
+
+
+
+
+
+ Start viewing as {{ tenant }}
+
+
+
+
+
+
diff --git a/ui/apps/console/app/components/platform/TenantDirectory.vue b/ui/apps/console/app/components/platform/TenantDirectory.vue
new file mode 100644
index 0000000..ee5d815
--- /dev/null
+++ b/ui/apps/console/app/components/platform/TenantDirectory.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+ Teams and people
+
+
+
+
+ The directory could not be reached, so teams and people are not shown: {{ unavailable }}
+
+ Viewing as
+ {{ actingAs.user ?? 'the tenant itself' }}
+ in {{ actingAs.tenant }} — every request is recorded under
+ your own name.
+
+
+ Stop
+
+
+
diff --git a/ui/apps/console/app/pages/tenants/[name].vue b/ui/apps/console/app/pages/tenants/[name].vue
index a5b2005..0acc87b 100644
--- a/ui/apps/console/app/pages/tenants/[name].vue
+++ b/ui/apps/console/app/pages/tenants/[name].vue
@@ -11,7 +11,13 @@ import { ApusApiError } from '#core/utils/apiErrors'
import { parseQuotaBytes } from '#core/utils/storageUsage'
import { validateAllowedDomains } from '#core/utils/domainValidation'
import type { MetaItem } from '#design/components/MetaList.vue'
-import type { PolicyEntryResponse, PolicyKeyResponse } from '#core/utils/apiTypes'
+import type {
+ PolicyEntryResponse,
+ PolicyKeyResponse,
+ TenantDirectoryResponse,
+ DirectoryUserResponse,
+ PasswordResetResponse
+} from '#core/utils/apiTypes'
const route = useRoute()
const name = computed(() => String(route.params.name ?? ''))
@@ -93,6 +99,70 @@ async function save(): Promise {
}
}
+// -- Teams and people ------------------------------------------------------------------------
+// Loaded separately from the tenant itself, and its failure is its own: the API answers 200 with
+// `unavailableReason` when the identity provider is unreachable, so this only ever throws when
+// something is genuinely wrong with the request -- which must not take the rest of the page down.
+const tenantDirectory = ref(null)
+const directoryError = ref(null)
+const temporaryPassword = ref(null)
+
+async function loadDirectory(): Promise {
+ if (!name.value) return
+ try {
+ tenantDirectory.value = await api.getTenantDirectory(name.value)
+ directoryError.value = null
+ } catch (caught) {
+ tenantDirectory.value = null
+ directoryError.value = caught instanceof ApusApiError
+ ? caught.message
+ : 'Could not load this tenant\'s teams and people.'
+ }
+}
+
+watch(name, loadDirectory, { immediate: true })
+
+/**
+ * Team membership, fetched one team at a time when somebody opens it. Not up front: that would
+ * be one request per team on every page load, for a list most people only scan.
+ */
+const teamMembers = ref>({})
+
+async function loadTeamMembers(teamId: string): Promise {
+ try {
+ teamMembers.value = { ...teamMembers.value, [teamId]: await api.getTeamMembers(name.value, teamId) }
+ } catch (caught) {
+ directoryError.value = caught instanceof ApusApiError ? caught.message : 'Could not load that team.'
+ }
+}
+
+async function createTeam(displayName: string): Promise {
+ try {
+ await api.createTeam(name.value, { displayName })
+ await loadDirectory()
+ } catch (caught) {
+ directoryError.value = caught instanceof ApusApiError ? caught.message : 'Could not create that team.'
+ }
+}
+
+async function invite(payload: { email: string, displayName: string }): Promise {
+ try {
+ await api.inviteUser(name.value, { email: payload.email, displayName: payload.displayName || null })
+ await loadDirectory()
+ } catch (caught) {
+ directoryError.value = caught instanceof ApusApiError ? caught.message : 'Could not send that invitation.'
+ }
+}
+
+async function resetPassword(user: DirectoryUserResponse): Promise {
+ try {
+ temporaryPassword.value = await api.resetPassword(name.value, user.id)
+ directoryError.value = null
+ } catch (caught) {
+ directoryError.value = caught instanceof ApusApiError ? caught.message : 'Could not reset that password.'
+ }
+}
+
const metadata = computed(() => {
const current = tenant.value
if (!current) return []
@@ -195,6 +265,42 @@ const metadata = computed(() => {
+
+
+
+ {{ directoryError }}
+
+
+
+
+
+ Temporary password for {{ temporaryPassword.userId }} — copy it now, it is not shown again.
+
+
+ {{ temporaryPassword.temporaryPassword }}
+
+
+
+ Done
+
+
+
+
+
+
diff --git a/ui/apps/console/app/pages/tenants/index.vue b/ui/apps/console/app/pages/tenants/index.vue
index 0a9596f..6ecc6b0 100644
--- a/ui/apps/console/app/pages/tenants/index.vue
+++ b/ui/apps/console/app/pages/tenants/index.vue
@@ -6,16 +6,51 @@
* Observed usage is an observation, not a control -- Ceph enforces the quota, Apus only reports
* what it sees (see storageUsage.ts's module Javadoc).
*/
-import type { DataTableColumn } from '#design/components/DataTable.vue'
+import type { DataTableColumn, } from '#design/components/DataTable.vue'
+import type { DirectoryCountsResponse } from '#core/utils/apiTypes'
const { tenants, loading, error, refresh } = useTenants()
+const api = useApiClient()
const columns: DataTableColumn[] = [
{ key: 'name', label: 'Tenant' },
{ key: 'storage', label: 'Storage' },
+ { key: 'people', label: 'Teams / people', numeric: true, secondary: true },
{ key: 'domains', label: 'Domains', numeric: true, secondary: true },
{ key: 'namespace', label: 'Namespace', secondary: true }
]
+
+/**
+ * Counts per tenant, fetched one request at a time after the table is already on screen.
+ *
+ * Deliberately not part of the tenant list itself: these come from the identity provider, which
+ * is somebody else's service and will be slow or throttling at some point. Blocking the table on
+ * them would make every tenant unreadable whenever Microsoft has a bad minute, and the storage
+ * column — the one an operator actually scans — has nothing to do with the directory.
+ *
+ * A tenant whose counts fail simply keeps its dash. The API already answers 200 with a reason
+ * rather than an error, so this only catches a genuinely broken request.
+ */
+const counts = ref>({})
+
+watch(tenants, async list => {
+ for (const tenant of list) {
+ if (counts.value[tenant.name]) continue
+ try {
+ counts.value = { ...counts.value, [tenant.name]: await api.getDirectoryCounts(tenant.name) }
+ } catch {
+ // Leave it absent: the cell shows a dash, which is honest about not knowing.
+ }
+ }
+}, { immediate: true })
+
+function peopleLabel(name: string): string {
+ const row = counts.value[name]
+ // A dash, never "0 / 0" -- a zero here would say this tenant has nobody in it, which is
+ // something an administrator would act on.
+ if (!row || row.teams === null || row.users === null) return '—'
+ return `${row.teams} / ${row.users}`
+}
@@ -91,6 +126,10 @@ const columns: DataTableColumn[] = [
+
+ {{ peopleLabel(row.name) }}
+
+
{{ row.allowedHostingDomains.length }}
diff --git a/ui/apps/console/tests/nuxt/impersonation.nuxt.spec.ts b/ui/apps/console/tests/nuxt/impersonation.nuxt.spec.ts
new file mode 100644
index 0000000..bd89eef
--- /dev/null
+++ b/ui/apps/console/tests/nuxt/impersonation.nuxt.spec.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import Impersonation from '~/components/platform/ImpersonationPanel.vue'
+
+const USERS = [
+ { id: 'u1', displayName: 'Alice', email: 'alice@acme.example', privileged: false },
+ { id: 'u2', displayName: 'Carol', email: 'carol@acme.example', privileged: false }
+]
+
+describe('Impersonation', () => {
+ it('says plainly that the session is recorded', async () => {
+ // Impersonation is a feature whose whole safety story is the audit trail. Someone about to
+ // use it should be told that before they do, not discover it afterwards.
+ const wrapper = await mountSuspended(Impersonation, { props: { tenant: 'acme', users: USERS } })
+
+ expect(wrapper.text().toLowerCase()).toContain('recorded')
+ })
+
+ it('names the tenant being entered, so nobody acts in the wrong one', async () => {
+ const wrapper = await mountSuspended(Impersonation, { props: { tenant: 'acme', users: USERS } })
+
+ expect(wrapper.text()).toContain('acme')
+ })
+
+ it('offers acting as the tenant itself, not only as a named person', async () => {
+ // The "as org admin" case: seeing what the tenant sees without borrowing anybody's name.
+ const wrapper = await mountSuspended(Impersonation, { props: { tenant: 'acme', users: USERS } })
+
+ expect(wrapper.text()).toContain('the tenant itself')
+ })
+
+ it('still offers acting as the tenant when the directory listed nobody', async () => {
+ // A tenant with no members, or one whose directory is unreachable, must not lose the org
+ // admin view -- that is exactly when someone is trying to work out what is wrong.
+ const wrapper = await mountSuspended(Impersonation, { props: { tenant: 'acme', users: [] } })
+
+ expect(wrapper.text()).toContain('the tenant itself')
+ })
+})
diff --git a/ui/apps/console/tests/nuxt/tenantDirectory.nuxt.spec.ts b/ui/apps/console/tests/nuxt/tenantDirectory.nuxt.spec.ts
new file mode 100644
index 0000000..e4cb159
--- /dev/null
+++ b/ui/apps/console/tests/nuxt/tenantDirectory.nuxt.spec.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest'
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import TenantDirectory from '~/components/platform/TenantDirectory.vue'
+import type { TenantDirectoryResponse } from '#core/utils/apiTypes'
+
+function directory(overrides: Partial = {}): TenantDirectoryResponse {
+ return {
+ teams: [{ id: 't1', displayName: 'Builders', memberCount: 3 }],
+ users: [{ id: 'u1', displayName: 'Alice', email: 'alice@acme.example', privileged: false }],
+ unavailableReason: null,
+ ...overrides
+ }
+}
+
+describe('TenantDirectory', () => {
+ it('shows the teams and the people', async () => {
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: { tenant: 'acme', directory: directory(), canWrite: true }
+ })
+
+ expect(wrapper.text()).toContain('Builders')
+ expect(wrapper.text()).toContain('Alice')
+ })
+
+ it('says nothing about the size of a team it did not count, rather than showing a zero', async () => {
+ // Graph reports members@odata.count for the group being listed, not for each nested group in
+ // the result, so this is the normal case rather than an edge one. A zero here would be a lie
+ // somebody would act on; "Unknown size" against every row would be honest and useless.
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: {
+ tenant: 'acme',
+ directory: directory({ teams: [{ id: 't1', displayName: 'Builders', memberCount: null }] }),
+ canWrite: true
+ }
+ })
+
+ expect(wrapper.text()).not.toContain('0 members')
+ expect(wrapper.text()).not.toContain('members')
+ expect(wrapper.text()).toContain('Who is in it')
+ })
+
+ it('counts a team exactly once its membership has been loaded', async () => {
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: {
+ tenant: 'acme',
+ directory: directory({ teams: [{ id: 't1', displayName: 'Builders', memberCount: null }] }),
+ canWrite: true,
+ members: {
+ t1: [
+ { id: 'u1', displayName: 'Alice', email: 'alice@acme.example', privileged: false },
+ { id: 'u2', displayName: 'Carol', email: 'carol@acme.example', privileged: false }
+ ]
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('2 members')
+ })
+
+ it('says why the directory is unavailable instead of showing an empty list', async () => {
+ // An empty list reads as "this tenant has nobody in it". The reason is what stops somebody
+ // going to read server logs for something the server already told us.
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: {
+ tenant: 'acme',
+ directory: directory({ teams: [], users: [], unavailableReason: 'the directory is unavailable' }),
+ canWrite: true
+ }
+ })
+
+ expect(wrapper.text()).toContain('the directory is unavailable')
+ expect(wrapper.text()).not.toContain('No teams yet')
+ })
+
+ it('offers no password reset for a privileged account', async () => {
+ // The API refuses regardless. This only spares somebody pressing a button to be told no.
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: {
+ tenant: 'acme',
+ directory: directory({
+ users: [{ id: 'u-admin', displayName: 'Root', email: 'root@acme.example', privileged: true }]
+ }),
+ canWrite: true
+ }
+ })
+
+ expect(wrapper.text()).toContain('Directory admin')
+ })
+
+ it('shows who is in a team once it is opened', async () => {
+ // The assignment, rather than two lists side by side.
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: {
+ tenant: 'acme',
+ directory: directory(),
+ canWrite: true,
+ members: { t1: [{ id: 'u1', displayName: 'Alice', email: 'alice@acme.example', privileged: false }] }
+ }
+ })
+
+ await wrapper.find('button').trigger('click')
+
+ expect(wrapper.text()).toContain('alice@acme.example')
+ })
+
+ it('does not claim a team is empty while its membership is still loading', async () => {
+ // An empty list shown mid-request would read as a fact about the team.
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: { tenant: 'acme', directory: directory(), canWrite: true, members: {} }
+ })
+
+ await wrapper.find('button').trigger('click')
+
+ expect(wrapper.text()).toContain('Loading')
+ expect(wrapper.text()).not.toContain('Nobody is in this team yet')
+ })
+
+ it('hides every changing action from someone who may only look', async () => {
+ const wrapper = await mountSuspended(TenantDirectory, {
+ props: { tenant: 'acme', directory: directory(), canWrite: false }
+ })
+
+ expect(wrapper.text()).not.toContain('Invite')
+ expect(wrapper.text()).not.toContain('New team')
+ })
+})
diff --git a/ui/layers/core/app/composables/useApiClient.ts b/ui/layers/core/app/composables/useApiClient.ts
index 9fcb4fc..0a15aab 100644
--- a/ui/layers/core/app/composables/useApiClient.ts
+++ b/ui/layers/core/app/composables/useApiClient.ts
@@ -1,4 +1,8 @@
import { createApusApiClient, type ApusApiClient } from '#core/utils/apiClient'
+// Imported explicitly rather than relying on auto-import: this file is in a layer, and the app
+// that extends it only learns about a newly added composable once its generated types are
+// rebuilt -- which turns "added a composable" into a confusing type error in a different package.
+import { useImpersonation } from '#core/composables/useImpersonation'
/**
* Nuxt-facing entry point to the api module client. Thin on purpose -- all the actual logic
@@ -11,9 +15,14 @@ import { createApusApiClient, type ApusApiClient } from '#core/utils/apiClient'
export function useApiClient(): ApusApiClient {
const config = useRuntimeConfig()
const { getAccessToken } = useAuth()
+ const { readSession } = useImpersonation()
return createApusApiClient({
baseUrl: config.public.apiBaseUrl,
- getAccessToken
+ getAccessToken,
+ // Read at request time, not captured once: a session started or ended after this client was
+ // created must take effect on the very next call, not on the next page that happens to build
+ // a fresh client.
+ getImpersonation: readSession
})
}
diff --git a/ui/layers/core/app/composables/useImpersonation.ts b/ui/layers/core/app/composables/useImpersonation.ts
new file mode 100644
index 0000000..5cf2d05
--- /dev/null
+++ b/ui/layers/core/app/composables/useImpersonation.ts
@@ -0,0 +1,65 @@
+/**
+ * The impersonation session, if one is running.
+ *
+ * Held in `sessionStorage` rather than memory so a page reload does not silently drop it — a
+ * half-exited impersonation, where the banner is gone but the reader still believes they are
+ * looking at a tenant, is worse than either state on its own. Session storage also means it ends
+ * with the tab, which is the right lifetime for something you enter deliberately.
+ *
+ * Nothing here grants anything. The value becomes two request headers; the API's
+ * `ImpersonationPolicy` decides, strips the platform role, and refuses a tenant the caller may
+ * not act in. Writing a tenant name here that you have no business in produces a refusal, not
+ * access.
+ */
+
+export interface ImpersonationSession {
+ tenant: string
+ /** `null` means acting as the tenant itself — the "org admin" view — rather than as a person. */
+ user: string | null
+}
+
+const STORAGE_KEY = 'apus.impersonation'
+
+/** Read straight from storage rather than a module-level ref, so every caller agrees. */
+function read(): ImpersonationSession | null {
+ if (typeof sessionStorage === 'undefined') return null
+ const raw = sessionStorage.getItem(STORAGE_KEY)
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw) as ImpersonationSession
+ // A stored value with no tenant would send a header the API rejects on every request, and
+ // the reader would have no idea why. Treat it as no session at all.
+ return parsed && typeof parsed.tenant === 'string' && parsed.tenant.length > 0 ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+export function useImpersonation() {
+ const actingAs = useState('apus-impersonation', () => read())
+
+ function startImpersonating(tenant: string, user: string | null): void {
+ const session: ImpersonationSession = { tenant, user }
+ actingAs.value = session
+ if (typeof sessionStorage !== 'undefined') {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
+ }
+ // A full reload rather than a route change: every page holding already-fetched data would
+ // otherwise keep showing the platform admin's own view under a banner claiming otherwise.
+ if (typeof window !== 'undefined') {
+ window.location.reload()
+ }
+ }
+
+ function stopImpersonating(): void {
+ actingAs.value = null
+ if (typeof sessionStorage !== 'undefined') {
+ sessionStorage.removeItem(STORAGE_KEY)
+ }
+ if (typeof window !== 'undefined') {
+ window.location.reload()
+ }
+ }
+
+ return { actingAs, startImpersonating, stopImpersonating, readSession: read }
+}
diff --git a/ui/layers/core/app/utils/apiClient.ts b/ui/layers/core/app/utils/apiClient.ts
index 385aed2..63f3ada 100644
--- a/ui/layers/core/app/utils/apiClient.ts
+++ b/ui/layers/core/app/utils/apiClient.ts
@@ -7,9 +7,16 @@ import type {
BlueMapMapResponse,
BlueMapRenderResponse,
ClusterRenderResponse,
+ CreateTeamRequest,
CreateTenantRequest,
CreateWorldSourceRequest,
+ DirectoryCountsResponse,
+ DirectoryTeamResponse,
+ DirectoryUserResponse,
+ InviteUserRequest,
+ PasswordResetResponse,
RenderProgressEvent,
+ TenantDirectoryResponse,
TenantResponse,
TriggerRenderRequest,
UpdateTenantRequest,
@@ -30,6 +37,12 @@ export interface ApusApiClientOptions {
getAccessToken: () => Promise | string | null
/** Defaults to the global `fetch`; override in tests. */
fetchImpl?: FetchLike
+ /**
+ * The impersonation session, if one is active: `{ tenant, user }` where `user` is `null` to act
+ * as the tenant itself. Returning something here cannot grant access — the API decides — so
+ * this is a request to be served as somebody, never a claim to be them.
+ */
+ getImpersonation?: () => { tenant: string, user: string | null } | null
}
export interface SseHandlers {
@@ -57,6 +70,23 @@ export function createApusApiClient(options: ApusApiClientOptions) {
return await options.getAccessToken()
}
+ /**
+ * Impersonation is two headers and nothing else. Sending them can never grant anything: the
+ * API's `ImpersonationFilter` applies `ImpersonationPolicy`, which strips the platform role and
+ * refuses a tenant the caller may not act in.
+ *
+ * Applied to every request while a session is active — including the SSE stream — so a page
+ * that happens to make three calls does not answer as three different people.
+ */
+ function applyImpersonation(headers: Headers): void {
+ const actingAs = options.getImpersonation?.()
+ if (!actingAs?.tenant) return
+ headers.set('X-Apus-Act-As-Tenant', actingAs.tenant)
+ if (actingAs.user) {
+ headers.set('X-Apus-Act-As-User', actingAs.user)
+ }
+ }
+
async function request(path: string, init: RequestInit = {}): Promise {
const token = await resolveToken()
const headers = new Headers(init.headers)
@@ -67,6 +97,7 @@ export function createApusApiClient(options: ApusApiClientOptions) {
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
+ applyImpersonation(headers)
let response: Response
try {
@@ -120,6 +151,9 @@ export function createApusApiClient(options: ApusApiClientOptions) {
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
+ // The stream too, or a page viewed as a tenant would show that tenant's maps alongside the
+ // platform admin's own render progress.
+ applyImpersonation(headers)
let response: Response
try {
@@ -160,6 +194,36 @@ export function createApusApiClient(options: ApusApiClientOptions) {
body: JSON.stringify(body)
}),
+ // -- A tenant's teams and people ------------------------------------------------------------
+ // Reads never fail the page: the API answers 200 with `unavailableReason` set when the
+ // identity provider cannot be reached, so a tenant whose storage and renders are fine stays
+ // readable while Microsoft is throttling.
+ getDirectoryCounts: (tenant: string) =>
+ request(`/api/tenants/${encodeURIComponent(tenant)}/directory/counts`),
+ getTenantDirectory: (tenant: string) =>
+ request(`/api/tenants/${encodeURIComponent(tenant)}/directory`),
+ /** Who is in one team — the assignment, rather than the two lists side by side. */
+ getTeamMembers: (tenant: string, teamId: string) =>
+ request(
+ `/api/tenants/${encodeURIComponent(tenant)}/directory/teams/${encodeURIComponent(teamId)}/members`
+ ),
+ createTeam: (tenant: string, body: CreateTeamRequest) =>
+ request(`/api/tenants/${encodeURIComponent(tenant)}/directory/teams`, {
+ method: 'POST',
+ body: JSON.stringify(body)
+ }),
+ inviteUser: (tenant: string, body: InviteUserRequest) =>
+ request(`/api/tenants/${encodeURIComponent(tenant)}/directory/invitations`, {
+ method: 'POST',
+ body: JSON.stringify(body)
+ }),
+ /** The temporary password comes back once and is never retrievable again -- show it, never store it. */
+ resetPassword: (tenant: string, userId: string) =>
+ request(
+ `/api/tenants/${encodeURIComponent(tenant)}/directory/users/${encodeURIComponent(userId)}/password-reset`,
+ { method: 'POST' }
+ ),
+
// -- World sources: caller's own tenant -----------------------------------------------------
listSources: () => request('/api/sources'),
createSource: (body: CreateWorldSourceRequest) =>
diff --git a/ui/layers/core/app/utils/apiTypes.ts b/ui/layers/core/app/utils/apiTypes.ts
index 1f33498..ded6233 100644
--- a/ui/layers/core/app/utils/apiTypes.ts
+++ b/ui/layers/core/app/utils/apiTypes.ts
@@ -76,6 +76,58 @@ export interface TenantResponse {
conditions: ConditionResponse[]
}
+/** api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryResponses.java */
+export interface DirectoryTeamResponse {
+ id: string
+ displayName: string
+ /**
+ * `null` rather than `0` when the directory could be asked for the team but not for its size.
+ * A zero meaning "not counted" is a lie somebody would act on, and `null` is the one value a
+ * template cannot mistake for a number.
+ */
+ memberCount: number | null
+}
+
+export interface DirectoryUserResponse {
+ id: string
+ displayName: string
+ email: string
+ /**
+ * Holds a directory role that makes this account un-resettable through Apus. The API refuses
+ * regardless; this only exists so the console can disable the button rather than let somebody
+ * press it and be told no.
+ */
+ privileged: boolean
+}
+
+/** Counts beside a tenant. Both `null` together, with a reason, when the directory is unreachable. */
+export interface DirectoryCountsResponse {
+ teams: number | null
+ users: number | null
+ unavailableReason: string | null
+}
+
+export interface TenantDirectoryResponse {
+ teams: DirectoryTeamResponse[]
+ users: DirectoryUserResponse[]
+ unavailableReason: string | null
+}
+
+/** The temporary password exists here and nowhere else. Shown once, never stored. */
+export interface PasswordResetResponse {
+ userId: string
+ temporaryPassword: string
+}
+
+export interface CreateTeamRequest {
+ displayName: string
+}
+
+export interface InviteUserRequest {
+ email: string
+ displayName?: string | null
+}
+
/** `TenantResponse.StorageResponse` -- never carries Ceph credentials, quota only. */
export interface TenantStorageResponse {
quota: string | null