feat(permissions): Authentik permissions for automated sessions #22

Merged
KofTwentyTwo merged 1 commits from feature/mapper-automated-sessions into develop 2026-10-14 00:55:16 +00:00
4 changed files with 312 additions and 0 deletions

No files matched your search

+3
View File
@@ -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
+4
View File
@@ -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
@@ -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<String> permissions = new TreeSet<>();
String email = userId == null ? "" : userId.toString().trim();
try
{
JSONObject found = null;
int matches = 0;
List<JSONObject> 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<String> 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<String> automatedSessionPermissions(QInstance qInstance, AuthentikRuntime runtime, Set<String> permissions,
List<String> 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<String> 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.
@@ -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