diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e861c8..53e599b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +### Added (automated sessions) +- `AuthentikGroupPermissionMapper` resolves tokenless automated sessions by a unique active Authentik email match, applying direct and inherited group permissions, static fallbacks and the sign-in cut-over guard while preserving existing session permissions. + ### Added (table feature permissions) - Policies can grant table feature permissions (e.g. analyze, comment) registered through core TableFeaturePermissionsMetaData diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 08a91e7..74f0ed5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -703,6 +703,10 @@ The mapper is a `QAuthenticationModuleCustomizerInterface` component. It replace **Fallback.** If Authentik is unreachable at sign-in, the mapper uses the mirror. If the mirror is empty, it grants only what the static config provides. It never grants everything. +#### Automated sessions + +Server-created sessions (scheduled reports and SSH-key users of the SFTP gateway) have no token. `customizeAutomatedSessionForUser` treats `userId` as the email idReference used by `user_email` mode and requires exactly one Authentik user with that email (trimmed, case-insensitive), who must be active. It resolves the API user's direct groups and their ancestors through the same permission resolver, static fallbacks and cut-over guard as sign-in, including the host permission source while the guard blocks. Service accounts follow the same rule. Bootstrap grants match only the resolved user's immutable pk or uuid; email verification is never assumed. Unknown, duplicate, inactive, unreadable or truncated lookups grant only empty-group static fallbacks. Existing session permissions are always preserved; an API failure never grants all permissions. + --- ## 6. Data model diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java index 04cc3c4..58a9fd8 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -17,6 +17,9 @@ package com.kingsrook.qbits.authentik.permissions; +import java.io.Serializable; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -171,6 +174,132 @@ public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCust + /******************************************************************************* + ** Automated sessions have no claims. Resolve their email idReference to one + ** active API user before trusting any groups or immutable bootstrap id. + *******************************************************************************/ + @Override + public void customizeAutomatedSessionForUser(QInstance qInstance, QSession session, Serializable userId) + { + AuthentikRuntime runtime = runtime(); + Set permissions = new TreeSet<>(); + String email = userId == null ? "" : userId.toString().trim(); + try + { + JSONObject found = null; + int matches = 0; + List users = email.isEmpty() ? List.of() : client(qInstance).getAllPages( + "/core/users/?email=" + URLEncoder.encode(email, StandardCharsets.UTF_8) + "&include_groups=true&include_roles=false", 10); + for(JSONObject user : users) + { + if(email.equalsIgnoreCase(user.optString("email", "").trim())) + { + found = user; + matches++; + } + } + // A bounded, possibly truncated lookup cannot prove uniqueness. + if(users.size() < 10 && matches == 1 && found.optBoolean("is_active", false)) + { + List groups = new ArrayList<>(); + JSONArray groupObjects = found.optJSONArray("groups_obj"); + if(groupObjects != null) + { + for(int i = 0; i < groupObjects.length(); i++) + { + groups.add(groupObjects.getJSONObject(i).getString("name")); + } + } + else if(found.opt("groups") instanceof JSONArray groupPks && !groupPks.isEmpty()) + { + GroupGraph graph = client(qInstance).groupGraph(); + for(int i = 0; i < groupPks.length(); i++) + { + GroupGraph.Node group = graph.byPk(groupPks.getString(i)); + if(group != null) + { + groups.add(group.name()); + } + } + } + String pk = found.optString("pk", ""); + String sub = runtime != null && runtime.getConfig().getResolvedBootstrapSubjects().contains(pk) ? pk : found.optString("uuid", pk); + String userEmail = found.getString("email").trim(); + boolean verified = found.optBoolean("email_verified", false); + permissions.addAll(resolve(qInstance, groups, sub, userEmail, verified)); + if(runtime != null) + { + permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + permissions = automatedSessionPermissions(qInstance, runtime, permissions, groups, sub, userEmail, verified); + } + } + else + { + LOG.info("Automated session has no unique active Authentik user; granting only the static fallbacks"); + } + } + catch(Exception e) + { + permissions.clear(); + // Exception messages can contain the lookup email; log only the type. + LOG.warn("Authentik could not be read for an automated session; granting only the static fallbacks", logPair("errorType", e.getClass().getSimpleName())); + } + if(runtime != null) + { + permissions.addAll(AccessComparison.staticPermissions(runtime.getConfig(), List.of())); + } + if(session.getPermissions() != null) + { + permissions.addAll(session.getPermissions()); + } + session.setPermissions(permissions); + } + + + + /******************************************************************************* + ** Mirror the sign-in cut-over floor without changing the claims path. + *******************************************************************************/ + private Set automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set permissions, + List groups, String sub, String email, boolean verified) + { + runtime.markMapperInvoked(); + AccessComparison.State state; + try + { + state = AccessComparison.state(runtime, client(qInstance)); + } + catch(Exception e) + { + state = new AccessComparison.State(AccessComparison.Status.BLOCKED, "The cut-over guard could not run", java.time.Instant.now()); + } + if(state.enforced()) + { + return (permissions); + } + Set floor = new TreeSet<>(AccessComparison.staticPermissions(runtime.getConfig(), groups)); + for(String group : groups) + { + floor.addAll(staticGroupPermissions.getOrDefault(group, List.of())); + } + if(!runtime.getConfig().getResolvedBootstrapSubjects().isEmpty() && isBootstrap(qInstance, runtime, sub, email, verified)) + { + for(IdentityPermission permission : IdentityPermission.values()) + { + floor.add(permission.getSessionPermission()); + } + } + AuthentikHostPermissionSource source = runtime.getConfig().getHostPermissionSource(); + if(source != null) + { + floor.addAll(source.userPermissions(email, groups)); + } + LOG.warn("Policies are not enforced for this automated session; granting today's access", logPair("status", state.status())); + return (floor); + } + + + /******************************************************************************* ** The permissions for a user's group names and token identity (sub, email, ** email_verified). Bootstrap operators are matched on immutable ids only. diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java index 95f594e..43a55e5 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -17,11 +17,13 @@ package com.kingsrook.qbits.authentik.permissions; +import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import com.kingsrook.qbits.authentik.AuthentikQBitConfig; +import com.kingsrook.qbits.authentik.AuthentikRuntime; import com.kingsrook.qbits.authentik.AuthentikTestSupport; import com.kingsrook.qbits.authentik.client.FixtureActionUtil; import com.kingsrook.qbits.authentik.client.FixtureRouter; @@ -35,6 +37,9 @@ import org.json.JSONObject; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; @@ -130,6 +135,177 @@ class AuthentikGroupPermissionMapperTest + /******************************************************************************* + ** Direct API groups grant their own and inherited permissions to both + ** people and service accounts, preserving the host's session permissions. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = { "internal", "service_account" }) + void automatedSessionsResolveGroups(String type) + { + JSONObject user = user(99, " Person@Example.Test ").put("type", type) + .put("groups_obj", new JSONArray().put(new JSONObject().put("name", "child"))); + usersWithEmail(user, user(100, "other@example.test")); + FixtureActionUtil.groupGraphBody = new JSONObject() + .put("pagination", new JSONObject().put("count", 2).put("current", 1).put("total_pages", 1)) + .put("results", new JSONArray() + .put(new JSONObject().put("pk", "parent-pk").put("name", "parent").put("attributes", + new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read")))) + .put(new JSONObject().put("pk", "child-pk").put("name", "child").put("parents", new JSONArray().put("parent-pk")).put("attributes", + new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.groups.manage"))))).toString(); + config.withStaticGroupPermissions(Map.of("child", List.of("config.fallback"))); + QSession session = new QSession(); + session.setPermissions(Set.of("already.there")); + new AuthentikGroupPermissionMapper().withGroupPermissions("child", List.of("mapper.fallback")) + .customizeAutomatedSessionForUser(qInstance, session, " person@example.test "); + assertThat(session.getPermissions()).containsExactlyInAnyOrder("already.there", "config.fallback", "mapper.fallback", + IdentityPermission.USERS_READ.getSessionPermission(), IdentityPermission.GROUPS_MANAGE.getSessionPermission()); + assertThat(FixtureActionUtil.REQUESTS).anySatisfy(request -> assertThat(request.getURI().getRawQuery()) + .contains("email=person%40example.test", "include_groups=true", "include_roles=false")); + } + + + + /******************************************************************************* + ** Unknown, blank, ambiguous and inactive identities never acquire groups, + ** host permissions or bootstrap permissions from an email alone. + *******************************************************************************/ + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { "unknown", "duplicate", "inactive", "missing-active", "bounded" }) + void automatedSessionsRejectUnprovenUsers(String scenario) + { + JSONObject candidate = user(42, "owner@example.test").put("groups_obj", + new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins"))); + if("duplicate".equals(scenario)) + { + usersWithEmail(candidate, user(99, " OWNER@example.test ").put("is_active", false)); + } + else if("inactive".equals(scenario)) + { + usersWithEmail(candidate.put("is_active", false)); + } + else if("missing-active".equals(scenario)) + { + candidate.remove("is_active"); + usersWithEmail(candidate); + } + else if("bounded".equals(scenario)) + { + JSONObject[] candidates = new JSONObject[10]; + java.util.Arrays.fill(candidates, user(99, "other@example.test")); + candidates[0] = candidate; + usersWithEmail(candidates); + } + else + { + usersWithEmail(); + } + config.withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback"))) + .withHostPermissionSource((email, groups) -> Set.of("host.permission")); + QSession session = new QSession(); + session.setPermissions(Set.of("already.there")); + new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback")) + .customizeAutomatedSessionForUser(qInstance, session, scenario == null || scenario.isEmpty() ? scenario : "owner@example.test"); + assertThat(session.getPermissions()).containsExactly("already.there"); + } + + + + /******************************************************************************* + ** Failed API lookups and absent runtimes keep only existing permissions. + *******************************************************************************/ + @Test + void automatedSessionLookupFailure() + { + FixtureActionUtil.handler = request -> FixtureActionUtil.response(403, "{\"detail\":\"Forbidden\"}"); + QSession session = new QSession(); + session.setPermissions(Set.of("already.there")); + new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "owner@example.test"); + assertThat(session.getPermissions()).containsExactly("already.there"); + AuthentikGroupPermissionMapper noRuntime = new AuthentikGroupPermissionMapper() + { + /******************************************************************************* + ** Deliberately missing backend. + *******************************************************************************/ + @Override + protected String getApiBackendName() + { + return ("missingAutomatedBackend"); + } + }; + noRuntime.customizeAutomatedSessionForUser(qInstance, session, "owner@example.test"); + assertThat(session.getPermissions()).containsExactly("already.there"); + } + + + + /******************************************************************************* + ** A readable user's direct group names retain static fallbacks when the + ** group graph fails; group UUIDs can also be resolved when objects are absent. + *******************************************************************************/ + @Test + void automatedGroupFallbacks() + { + JSONObject child = new JSONObject(FixtureActionUtil.groupGraphBody).getJSONArray("results").toList().stream() + .map(value -> new JSONObject((Map) value)).filter(group -> "phase1-child-of-identity-admins".equals(group.optString("name"))) + .findFirst().orElseThrow(); + usersWithEmail(user(99, "person@example.test").put("groups", new JSONArray().put(child.getString("pk")))); + QSession session = new QSession(); + new AuthentikGroupPermissionMapper().customizeAutomatedSessionForUser(qInstance, session, "person@example.test"); + assertThat(session.getPermissions()).contains(IdentityPermission.USERS_READ.getSessionPermission()); + + usersWithEmail(user(99, "person@example.test").put("groups_obj", new JSONArray().put(child))); + FixtureActionUtil.groupGraphBody = "not json"; + config.withStaticGroupPermissions(Map.of(child.getString("name"), List.of("config.fallback"))); + session = new QSession(); + new AuthentikGroupPermissionMapper().withGroupPermissions(child.getString("name"), List.of("mapper.fallback")) + .customizeAutomatedSessionForUser(qInstance, session, "person@example.test"); + assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback"); + } + + + + /******************************************************************************* + ** The cut-over floor is identical to sign-in and bootstrap recognizes both + ** API immutable ids without inventing an email_verified claim. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = { "42", "uuid-42", "not-this-user" }) + void automatedCutoverFloor(String bootstrapSubject) + { + config.withBootstrapOperatorSubjects(bootstrapSubject) + .withStaticGroupPermissions(Map.of("phase1-child-of-identity-admins", List.of("config.fallback"))) + .withHostPermissionSource((email, groups) -> + { + assertThat(email).isEqualTo("owner@example.test"); + assertThat(groups).containsExactly("phase1-child-of-identity-admins"); + return (Set.of("host.permission")); + }); + AuthentikRuntime.forBackend(config.getApiBackendName()).markMapperInvoked(); + AuthentikRuntime.forBackend(config.getApiBackendName()).setMapperState( + new AccessComparison.State(AccessComparison.Status.BLOCKED, "Test guard", Instant.now())); + usersWithEmail(user(42, "owner@example.test").put("groups_obj", + new JSONArray().put(new JSONObject().put("name", "phase1-child-of-identity-admins")))); + QSession session = new QSession(); + new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("mapper.fallback")) + .customizeAutomatedSessionForUser(qInstance, session, "owner@example.test"); + if("not-this-user".equals(bootstrapSubject)) + { + assertThat(session.getPermissions()).containsExactlyInAnyOrder("config.fallback", "mapper.fallback", "host.permission"); + } + else + { + assertThat(session.getPermissions()).contains("config.fallback", "mapper.fallback", "host.permission"); + for(IdentityPermission permission : IdentityPermission.values()) + { + assertThat(session.getPermissions()).contains(permission.getSessionPermission()); + } + } + } + + + /******************************************************************************* ** Bootstrap operators are keyed on immutable ids, never on an email: a ** spoofed email (duplicate, or a different user) is denied, an unverified