Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.directory;

import java.util.List;

/**
* What Apus needs from the identity provider, and nothing more.
*
* <p>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.
*
* <p>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.
*
* <p><b>Nothing here checks authorisation.</b> 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<DirectoryTeam> teamsIn(String groupId);

/** The members of a tenant's group, each carrying whatever privileged roles they hold. */
List<DirectoryUser> 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.
*
* <p>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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.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.
*
* <p>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<DirectoryTeam> teamsIn(String groupId) {
throw new DirectoryUnavailableException(MESSAGE);
}

@Override
public List<DirectoryUser> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.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.
*
* <p>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.
*
* <p>So the limit lives here, as a guard the operations <em>call</em> 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.
*
* <p>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.
*
* <p>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<String> 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<Set<String>> managedGroups = new AtomicReference<>(Set.of());

/** Replaces the managed-group set. Called by the tenant index, not by request handling. */
public void setManagedGroups(Set<String> groups) {
managedGroups.set(groups == null ? Set.of() : Set.copyOf(groups));
}

/** The groups currently considered Apus's to touch. */
public Set<String> 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.
*
* <p>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");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.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;
}
}
Loading
Loading