feat(permissions): Authentik permissions for automated sessions #22
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+129
@@ -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.
|
||||
|
||||
+176
@@ -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
|
||||
|
||||
Reference in new issue
Block a user