From 38fc184684a6e54620eecec75e6bb449183245a4 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:49:00 +0200 Subject: [PATCH 1/9] docs: design teams and users through Entra Two problems that look like one, and conflating them would put a directory-wide credential in a browser. Reading who someone is is a token question. The 'No tenant to show' bug is now explained rather than suspected: PrincipalResolver reads an 'organization' claim, and the app registration has groupMembershipClaims: null and optionalClaims: null. Entra was never going to emit it. Mapping an Entra group to a Tenant fixes it with no Graph, no secret and no second registration. Changing the directory needs Graph application permissions, and those must not go on the Apus app registration: it is a SPA, a public client, which cannot hold a secret or use client credentials at all -- permissions there would be unusable and would invite the assumption that the browser holds them. So a second confidential registration used only by the api module, server-side. Workload identity federation would avoid a secret entirely, but it is unavailable here and this was checked: the cluster's OIDC issuer is https://api.k8s.onelite.feather:6443, an internal name Entra cannot reach. The permissions are directory-wide and Entra offers no narrower variant, so the narrowing lives in Apus's code -- refuse any group no Tenant claims, refuse any user outside one, refuse password resets against privileged directory roles -- and the tests are written from the attacker's side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../2026-08-16-teams-and-users-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-teams-and-users-design.md 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..5b39c2c --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-teams-and-users-design.md @@ -0,0 +1,152 @@ +# 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.ReadWrite.All` | reset a password (`passwordProfile`) | Application | +| `User.Read.All` | show users and assignments | Application | + +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. + +| Method | Path | Role | +| --- | --- | --- | +| `GET` | `/api/tenants/{name}/teams` | `platform-admin`, or `tenant-owner` of that tenant | +| `POST` | `/api/tenants/{name}/teams` | as above | +| `GET` | `/api/tenants/{name}/users` | as above | +| `POST` | `/api/tenants/{name}/users/invitations` | as above | +| `POST` | `/api/tenants/{name}/users/{id}/password-reset` | as above | + +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. From 00dd4739a29cfc37340b1848befc6247891ecc3f Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Mon, 17 Aug 2026 00:06:28 +0200 Subject: [PATCH 2/9] feat(api): teams, users and impersonation through the directory Subsystems C and D. The Graph permissions behind this are directory-wide -- Entra offers no 'these groups only' variant of Group.ReadWrite.All -- so the whole design is about where the narrowing lives. DirectoryGuard is that narrowing, and it is closed by default: an instance that has not been told which groups are managed refuses everything, which is also its state at startup and if the tenant index ever fails to load. It refuses any group no Tenant claims, any user outside one, any password reset aimed at a privileged directory account, and any reset of the caller's own password. Its tests are written from the attacker's side. Tenant.spec.identity.groupId ties a tenant to an Entra group, which fixes the 'No tenant to show' bug the tenant app has had since SSO was set up: PrincipalResolver read an 'organization' claim, and the app registration has groupMembershipClaims: null and optionalClaims: null. The claim was not mis-mapped, it never existed. Groups are the fallback; an explicit organisation claim still wins where a broker emits one. Impersonation only ever narrows. The effective principal never holds platform-admin and never holds a role its caller does not, so there is no combination of headers that lets anyone do something they could not already do as themselves -- only in a different tenant. Applied in one filter rather than per controller, so controllers written later cannot forget it. The directory being down is not a tenant page failing: reads report the panel unavailable with a reason, and a count is null rather than 0, because a zero meaning 'we could not ask' is a lie an administrator would act on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../apus/api/directory/Directory.java | 75 ++++ .../apus/api/directory/DirectoryFactory.java | 98 +++++ .../apus/api/directory/DirectoryGuard.java | 160 ++++++++ .../apus/api/directory/DirectoryTeam.java | 44 +++ .../DirectoryUnavailableException.java | 42 +++ .../apus/api/directory/DirectoryUser.java | 53 +++ .../apus/api/directory/GraphDirectory.java | 355 ++++++++++++++++++ .../GraphDirectoryConfiguration.java | 93 +++++ .../apus/api/directory/GraphResponses.java | 108 ++++++ .../apus/api/directory/TenantGroupIndex.java | 117 ++++++ .../api/directory/TenantGroupIndexLoader.java | 77 ++++ .../api/rest/directory/DirectoryRequests.java | 40 ++ .../rest/directory/DirectoryResponses.java | 92 +++++ .../directory/TenantDirectoryController.java | 229 +++++++++++ .../apus/api/security/Impersonation.java | 42 +++ .../api/security/ImpersonationFilter.java | 125 ++++++ .../api/security/ImpersonationPolicy.java | 74 ++++ .../apus/api/security/Role.java | 13 + .../apus/api/support/PrincipalResolver.java | 60 +++ .../api/directory/DirectoryGuardTest.java | 172 +++++++++ .../api/directory/GraphResponsesTest.java | 115 ++++++ .../api/directory/TenantGroupIndexTest.java | 98 +++++ .../api/rest/directory/InMemoryDirectory.java | 114 ++++++ .../TenantDirectoryControllerTest.java | 263 +++++++++++++ .../api/security/ImpersonationPolicyTest.java | 119 ++++++ .../apus/api/security/RoleTest.java | 13 + .../support/PrincipalResolverGroupsTest.java | 108 ++++++ .../apus/operator/api/TenantSpec.java | 41 ++ 28 files changed, 2940 insertions(+) create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/Directory.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryFactory.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryGuard.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryTeam.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUnavailableException.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/DirectoryUser.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectory.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/GraphDirectoryConfiguration.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/GraphResponses.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndex.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/directory/TenantGroupIndexLoader.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryRequests.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/directory/DirectoryResponses.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/Impersonation.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationFilter.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationPolicy.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/directory/DirectoryGuardTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/directory/GraphResponsesTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/directory/TenantGroupIndexTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/directory/InMemoryDirectory.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/security/ImpersonationPolicyTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverGroupsTest.java 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..5aa9f32 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryController.java @@ -0,0 +1,229 @@ +/** + * 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())); + } + } + + @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..5f7c9d7 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/security/ImpersonationFilter.java @@ -0,0 +1,125 @@ +/** + * 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.http.HttpRequest; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.http.annotation.ServerFilter; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.filters.SecurityFilter; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Applies {@link ImpersonationPolicy} to a request that asks to be served as somebody else. + * + *

Two headers, both optional: + * + *

    + *
  • {@code X-Apus-Act-As-Tenant} -- the tenant to act within. Required to impersonate at all. + *
  • {@code X-Apus-Act-As-User} -- the person to appear as. Omit to act as the tenant itself, + * which is the "as org admin" case. + *
+ * + *

Done in a filter, once, rather than in each controller. Every controller in this + * module already resolves its caller through {@link PrincipalResolver}; replacing the request's + * {@link Authentication} here means all of them -- including ones written later by someone who + * has never heard of this feature -- see the impersonated principal and enforce their own rules + * against it, with no chance of one forgetting. + * + *

A refused impersonation fails the request. It does not quietly fall back to the real + * principal: someone who asked to act as a tenant and was served as themselves would read the + * answer as that tenant's, which is a worse outcome than an error. + * + *

Every impersonated request is logged with the real subject before it is served. + */ +@ServerFilter("/api/**") +public class ImpersonationFilter { + + 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..e45a369 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/directory/TenantDirectoryControllerTest.java @@ -0,0 +1,263 @@ +/** + * 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")); + } + + // --- 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/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/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"; From 9878b25029580b08661419395961d4fb1cafe85d Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Mon, 17 Aug 2026 00:17:17 +0200 Subject: [PATCH 3/9] feat(console): teams, people, impersonation and directory counts The console side of subsystems C and D, and the last three of the five things the goal asked for. Tenant list gains a teams/people column, fetched per row after the table is already on screen. Deliberately not part of the tenant list itself: those counts come from the identity provider, and blocking the table on somebody else's service would make every tenant unreadable whenever it has a bad minute. A tenant whose counts fail keeps a dash -- never '0 / 0', which would say it has nobody in it. Tenant detail gains teams and people with create, invite and reset. Two rules run through the component and both are about not lying: unavailable is not empty, and an unknown team size is not zero. The temporary password is shown once, in a box that says it will not be shown again. Impersonation is two request headers and a banner. The banner lives in the layout rather than on the page that starts the session, because that is the point -- once it is running every page answers as somebody else, and a banner you stop seeing when you navigate is a banner you stop seeing exactly when you have forgotten. Sending the headers grants nothing: the API strips the platform role and refuses a tenant the caller may not act in. The chart wires the directory credential from a Secret via secretKeyRef, off by default, and its comment says plainly that the registration must not be the one the browser uses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- ...tenants.bluemap.onelitefeather.net-v1.yaml | 5 + .../templates/api-deployment.yaml | 17 ++ deploy/charts/apus-platform/values.yaml | 25 +++ .../platform/ImpersonationPanel.vue | 93 +++++++++++ .../components/platform/TenantDirectory.vue | 151 ++++++++++++++++++ ui/apps/console/app/layouts/default.vue | 28 ++++ ui/apps/console/app/pages/tenants/[name].vue | 92 ++++++++++- ui/apps/console/app/pages/tenants/index.vue | 41 ++++- .../tests/nuxt/impersonation.nuxt.spec.ts | 39 +++++ .../tests/nuxt/tenantDirectory.nuxt.spec.ts | 77 +++++++++ .../core/app/composables/useApiClient.ts | 11 +- .../core/app/composables/useImpersonation.ts | 65 ++++++++ ui/layers/core/app/utils/apiClient.ts | 59 +++++++ ui/layers/core/app/utils/apiTypes.ts | 52 ++++++ 14 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 ui/apps/console/app/components/platform/ImpersonationPanel.vue create mode 100644 ui/apps/console/app/components/platform/TenantDirectory.vue create mode 100644 ui/apps/console/tests/nuxt/impersonation.nuxt.spec.ts create mode 100644 ui/apps/console/tests/nuxt/tenantDirectory.nuxt.spec.ts create mode 100644 ui/layers/core/app/composables/useImpersonation.ts 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/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..525d6fd --- /dev/null +++ b/ui/apps/console/app/components/platform/TenantDirectory.vue @@ -0,0 +1,151 @@ + + + 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 @@ + +