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: + * + *

+ * + *

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 @@ + + + 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 @@ + + + diff --git a/ui/apps/console/app/layouts/default.vue b/ui/apps/console/app/layouts/default.vue index d24c803..973258c 100644 --- a/ui/apps/console/app/layouts/default.vue +++ b/ui/apps/console/app/layouts/default.vue @@ -1,3 +1,15 @@ + +