Phase 1: read-only MVP (memberships, sessions, events, effective permissions, drift report, mapper) #4

Merged
KofTwentyTwo merged 4 commits from feature/phase-1-read-mvp into develop 2026-10-12 11:08:37 +00:00
49 changed files with 5942 additions and 242 deletions

No files matched your search

+3 -1
View File
@@ -32,9 +32,11 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- Writes go to the guard, which refuses them in 0.1.
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups). `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
## Invariants (do not break)
+21 -1
View File
@@ -4,7 +4,27 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
### Added
### Added (phase 1, read-only MVP)
- Tables: `authentikGroupMember` (virtual membership table: by group, by user, by key, or a bounded sweep), `authentikSession` and `authentikEvent` (both need `identity.audit.read`).
- `GroupGraph` and `EffectivePermissionResolver`: groups, including inherited parents, become QQQ permissions. `identity.*` and catalog wildcards are expanded; unknown names are reported, never granted. Groups show inherited superuser status and effective permissions; users show effective permissions.
- `AuthentikGroupPermissionMapper`: an authentication customizer that turns the token's `groups` claim into QQQ session permissions through the resolver. It supports static fallbacks and `bootstrapOperators`; if Authentik cannot be read it grants only the static fallbacks, never everything.
- Review hardening:
- `search` accepts plain text only; Authentik's query language could reach sensitive fields.
- The group graph is thread-safe.
- A failed group-graph read shows "Unavailable", never empty permissions.
- Malformed `qqq.io/*` attributes are reported.
- Operator filters can't widen the user scope, so count and query agree.
- Membership rows are always scope-checked.
- Missing pagination is an error.
- Sorting by the key falls back to the default order.
- Booleans are strict.
- `..` keys are refused.
- https is required (except loopback or `allowInsecureHttp`).
- `groupScopeNamePrefix` is refused until implemented.
- Drift checks that cannot run report ERROR ("result unknown"); service-account tokens are filtered server-side; deleted targets are flagged; only human users count as identity admins.
- Identity drift report widget (`identity.admin`). It checks the server version, blueprint status, the service account (type, groups, superuser, protection, extra self-minted tokens), break-glass presence, the last identity administrator, `qqq.io/*` attribute validity, and service-account changes to protected or superuser objects (lockout detection).
### Added (phase 0)
- Phase 0 foundations: `AuthentikQBitConfig`, `AuthentikQBitProducer`, `AuthentikActionUtil` (bearer auth, page/page_size paging, filter mapping that rejects unsupported filters, 10 rps token-bucket throttle, log redaction), read-only Users and Groups tables, Identity app.
- Pinned Authentik 2026.8.3 OpenAPI schema and schema drift tests.
- Testcontainers contract tests against Authentik 2026.8.3 (bootstrap on an empty instance, superuser floor, blueprint re-apply safety, access-check behaviour) and a fixture recorder.
+3 -1
View File
@@ -32,9 +32,11 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
- Writes go to the guard, which refuses them in 0.1.
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
- `client/AuthentikTables` maps tables to the pinned schema (users, groups). `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
## Invariants (do not break)
+2 -1
View File
@@ -29,7 +29,8 @@ new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance);
This produces:
- an API backend `authentikApi`
- read-only tables (`authentikUser`, `authentikGroup`, ...)
- read-only tables `authentikUser`, `authentikGroup`, `authentikGroupMember`, `authentikSession` and `authentikEvent`
- the Identity Drift Report widget
- an app `authentikAdmin`, labelled "Identity", that hosts can nest, for example under Admin
Access is controlled by QQQ permissions:
+1 -1
View File
@@ -33,7 +33,7 @@ The agent cannot read production data. The rehearsal runs entirely in local Dock
git -C cluster-gitops show origin/main:tenants/kof22/website-authentik/blueprint-configmap.yaml > before.yaml
git -C cluster-gitops show origin/feat/kof22-identity-bootstrap:tenants/kof22/website-authentik/blueprint-configmap.yaml > after.yaml
```
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml`.
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml` from macOS, Linux or Git Bash. The script has already passed on a synthetic production-like dump: today's `main` blueprint with James re-pinned by the `present` entry, plus UI-made publishers.
- The script restores the dump into a throwaway Postgres and starts Authentik 2026.8.3 on the current blueprints. It then records every user's groups, switches to the phase 0 blueprints, re-applies both twice, and checks:
- nobody's memberships changed
- `james.maes` keeps `authentik Admins` and `kof22-admins`
+20 -1
View File
@@ -18,6 +18,25 @@ The Authentik qbit's progress through DESIGN.md section 11, with links. Updated
| 10 | Seed attributes, break-glass password | **owner** | checklist |
| 11 | Read-only on staging | after 6-10 | flip `AUTHENTIK_QBIT_ENABLED` |
## Phase 1: read-only MVP
| Item | State |
|---|---|
| Users, groups, memberships, sessions, events tables | done (phase 1 PR) |
| Effective permissions (resolver), inherited superuser | done |
| Identity drift report (widget) | done |
| Identity app under Admin, after Web Users (kof22) | Website-Backend draft PR #94 |
| `AuthentikGroupPermissionMapper` (sign-in permissions from groups) | done in the qbit; the kof22 cut-over (replacing `AuthentikAuthenticationCustomizer`) waits for owner item 10 |
| Code review (general + silent-failure) | done; all high and most medium findings fixed, with regression tests |
| Mirror tables, group tree, access-matrix widgets | next PRs |
## Contract test results (Authentik 2026.8.3)
`CI=true ./mvnw verify`: 48 unit tests and 27 contract tests, all green; JaCoCo gate met.
`CI=true ./mvnw verify`:
| Phase | Unit tests | Contract tests |
|---|---|---|
| 0 | 48 | 27 |
| 1 | 68 | 31 |
All green, JaCoCo gate met. CircleCI is not enabled for this repo yet (owner checklist D1).
+19 -14
View File
@@ -13,11 +13,13 @@
#
# Needs: docker, jq, python3 with PyYAML. Takes ~5 minutes.
set -euo pipefail
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep container paths as-is
DUMP=${1:?dump file}; BEFORE=${2:?configmap before}; AFTER=${3:?configmap after}
IMAGE=${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server:2026.8.3}
NET=ak-rehearsal; PORT=${PORT:-19900}
WORK=$(mktemp -d); BP="$WORK/blueprints"; mkdir -p "$BP"
BP_MOUNT="$BP"; command -v cygpath >/dev/null 2>&1 && BP_MOUNT=$(cygpath -w "$BP")
ADMIN_TOKEN="rehearsal-$(openssl rand -hex 16)"
API="http://localhost:$PORT/api/v3"
@@ -26,7 +28,7 @@ trap cleanup EXIT
extract() { # write every blueprint key of a ConfigMap into $BP
rm -f "$BP"/*.yaml
python3 - "$1" "$BP" <<'PY'
python3 - "$1" "$BP_MOUNT" <<'PY'
import sys, yaml
doc = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
for key, value in doc["data"].items():
@@ -38,12 +40,14 @@ PY
api() { curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API$1"; }
snapshot() { # users -> sorted groups, as TSV
api "/core/users/?page_size=100&include_groups=true&type=internal&type=external&type=service_account" \
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | sort
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | tr -d '\r' | sort
}
wait_blueprints() {
wait_blueprints() { # $1 = how many kof22* blueprint instances must exist
local want=${1:-1}
for _ in $(seq 1 90); do
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
if [ -n "$states" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
count=$(wc -w <<<"$states")
if [ "$count" -ge "$want" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
sleep 5
done
echo " blueprints did not all reach successful: $states"; api "/managed/blueprints/?page_size=100" | jq -r '.results[] | "\(.name) \(.status)"'; return 1
@@ -54,7 +58,8 @@ docker network create $NET >/dev/null
docker run -d --name akr-pg --network $NET -e POSTGRES_USER=authentik -e POSTGRES_PASSWORD=rehearsal -e POSTGRES_DB=authentik postgres:16-alpine >/dev/null
until docker exec akr-pg pg_isready -U authentik >/dev/null 2>&1; do sleep 1; done
docker cp "$DUMP" akr-pg:/tmp/authentik.dump
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || true
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || echo " (pg_restore reported warnings; usually role/ownership notices)"
docker exec akr-pg psql -U authentik -d authentik -tAc "select count(*) from authentik_core_user" | sed 's/^/ users restored: /'
echo "== 2. start Authentik with the CURRENT (main) blueprints"
extract "$BEFORE"
@@ -62,33 +67,33 @@ ENV=(-e AUTHENTIK_SECRET_KEY="$(openssl rand -hex 32)" -e AUTHENTIK_POSTGRESQL__
-e AUTHENTIK_POSTGRESQL__PASSWORD=rehearsal -e AUTHENTIK_POSTGRESQL__NAME=authentik -e AUTHENTIK_BOOTSTRAP_TOKEN="$ADMIN_TOKEN"
-e KOF22_OAUTH2_CLIENT_SECRET=rehearsal-not-the-real-secret -e AUTHENTIK_ERROR_REPORTING__ENABLED=false
-e AUTHENTIK_DISABLE_UPDATE_CHECK=true -e AUTHENTIK_EMAIL__HOST=localhost -e AUTHENTIK_EMAIL__PORT=1)
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP:/blueprints/custom" "$IMAGE" worker >/dev/null
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP:/blueprints/custom" "$IMAGE" server >/dev/null
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" worker >/dev/null
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" server >/dev/null
until curl -sf "http://localhost:$PORT/-/health/ready/" >/dev/null; do sleep 3; done
# The bootstrap token only applies to an EMPTY instance; on a restored database
# create a rehearsal token for akadmin-equivalent access through the shell.
docker exec akr-worker ak shell -c "
from authentik.core.models import Token, TokenIntents, User
u = User.objects.filter(is_superuser=True, is_active=True).exclude(username__startswith='ak-').order_by('pk').first()
u = next(x for x in User.objects.filter(is_active=True).exclude(username__startswith='ak-').order_by('pk') if x.is_superuser) # is_superuser is derived from groups
Token.objects.update_or_create(identifier='phase0-rehearsal', defaults={'user': u, 'intent': TokenIntents.INTENT_API, 'key': '$ADMIN_TOKEN', 'expiring': False})
print('rehearsal token for', u.username)" 2>/dev/null | grep rehearsal
wait_blueprints
print('rehearsal token for', u.username)" 2>&1 | grep -E 'rehearsal token|Error|error' | tail -3 | grep 'rehearsal token' || { echo "could not create the rehearsal API token in the restored copy"; exit 1; }
wait_blueprints 1
snapshot > "$WORK/before.tsv"; echo " $(wc -l < "$WORK/before.tsv") users recorded (step 1, freeze)"
echo "== 3. switch to the phase 0 blueprints (as an ArgoCD sync would)"
extract "$AFTER"
docker restart akr-worker >/dev/null # a restart triggers discovery like a ConfigMap change
wait_blueprints
wait_blueprints 2
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints
sleep 30; wait_blueprints 2
echo "== 4. checks"
snapshot > "$WORK/after.tsv"
fail=0
if ! diff <(grep -v -E '^(qqq-kof22-admin-idadmin|kof22-breakglass)\b' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
if ! diff <(awk -F'\t' '$1 != "qqq-kof22-admin-idadmin" && $1 != "kof22-breakglass"' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
echo " FAIL memberships changed:"; diff "$WORK/before.tsv" "$WORK/after.tsv" || true; fail=1
else echo " ok existing users' memberships unchanged"; fi
grep -q $'^james.maes\t.*authentik Admins.*kof22-admins' "$WORK/after.tsv" && echo " ok james.maes keeps authentik Admins + kof22-admins" || { echo " FAIL james.maes groups"; fail=1; }
@@ -103,6 +108,6 @@ for name in kof22-oauth2-apps kof22-identity-bootstrap; do
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
done
sleep 30; wait_blueprints
sleep 30; wait_blueprints 2
snapshot | diff - "$WORK/after.tsv" >/dev/null && echo " ok stable across re-apply" || { echo " FAIL changed on re-apply"; fail=1; }
[ $fail = 0 ] && echo "REHEARSAL PASSED" || { echo "REHEARSAL FAILED"; exit 1; }
@@ -75,6 +75,7 @@ public class AuthentikQBitConfig implements QBitConfig
private Integer connectTimeoutMillis = 5_000;
private Integer socketTimeoutMillis = 30_000;
private String expectedServerVersion = "2026.8";
private Boolean allowInsecureHttp = false;
@@ -98,6 +99,10 @@ public class AuthentikQBitConfig implements QBitConfig
{
errors.add("baseUrl must be an absolute http(s) URL: " + baseUrl);
}
else if(uri.getScheme().equals("http") && !Boolean.TRUE.equals(allowInsecureHttp) && !isLoopback(uri.getHost()))
{
errors.add("baseUrl must use https (the API token would travel in cleartext); http is allowed only for localhost or with allowInsecureHttp: " + baseUrl);
}
else if(uri.getPath() != null && uri.getPath().contains("/api/v3"))
{
errors.add("baseUrl must be the host root; /api/v3 is appended by the qbit: " + baseUrl);
@@ -124,6 +129,11 @@ public class AuthentikQBitConfig implements QBitConfig
errors.add("tableNamePrefix must start with a letter and contain only letters and digits: " + tableNamePrefix);
}
if(StringUtils.hasContent(groupScopeNamePrefix))
{
errors.add("groupScopeNamePrefix is not available in this release of qbit-authentik");
}
Set<AuthentikFeature> requested = features == null ? EnumSet.noneOf(AuthentikFeature.class) : features;
if(!requested.contains(AuthentikFeature.READ))
{
@@ -175,6 +185,38 @@ public class AuthentikQBitConfig implements QBitConfig
/*******************************************************************************
**
*******************************************************************************/
private static boolean isLoopback(String host)
{
return ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "[::1]".equals(host) || "::1".equals(host));
}
/*******************************************************************************
** Getter for allowInsecureHttp
*******************************************************************************/
public Boolean getAllowInsecureHttp()
{
return (this.allowInsecureHttp);
}
/*******************************************************************************
** Fluent setter for allowInsecureHttp (plain http to a non-loopback host;
** for tests and private networks only)
*******************************************************************************/
public AuthentikQBitConfig withAllowInsecureHttp(Boolean allowInsecureHttp)
{
this.allowInsecureHttp = allowInsecureHttp;
return (this);
}
/*******************************************************************************
** Prefix a component name with the tableNamePrefix (camel-cased join).
*******************************************************************************/
@@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
@@ -42,6 +43,8 @@ public class AuthentikRuntime
private final AuthentikGuard guard;
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
private final Memoization<String, String> responseCache;
private final Memoization<String, GroupGraph> groupGraphCache;
private volatile java.util.Set<String> permissionCatalog;
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
@@ -55,6 +58,7 @@ public class AuthentikRuntime
this.throttle = new TokenBucketThrottle(config.getThrottleRequestsPerSecond(), config.getThrottleBurst());
this.guard = new AuthentikGuard(config);
this.responseCache = new Memoization<String, String>(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500);
this.groupGraphCache = new Memoization<String, GroupGraph>(Duration.ofSeconds(config.getGroupCacheTtlSeconds())).withMaxSize(1);
}
@@ -121,6 +125,34 @@ public class AuthentikRuntime
public void invalidateCache()
{
responseCache.clear();
groupGraphCache.clear();
}
/*******************************************************************************
** Cache for the group graph (groupCacheTtlSeconds; 0 disables).
*******************************************************************************/
public Memoization<String, GroupGraph> getGroupGraphCache()
{
return (groupGraphCache);
}
/*******************************************************************************
** QQQ permission names that qqq.io/permissions may grant: every permission
** the instance's metadata defines (PermissionsHelper), computed once.
*******************************************************************************/
public java.util.Set<String> getPermissionCatalog()
{
if(permissionCatalog == null)
{
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
permissionCatalog = qInstance == null ? java.util.Set.of()
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
}
return (permissionCatalog);
}
@@ -27,7 +27,10 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import com.kingsrook.qqq.backend.module.api.model.OutboundAPILog;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.apache.http.Header;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.NameValuePair;
@@ -98,6 +102,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
private static final AtomicBoolean VERSION_CHECKED = new AtomicBoolean(false);
/** Maximum memberships / groups read by one sweep (bounded, never silently: see memberships()). */
public static final int MAX_SWEEP_OBJECTS = 20_000;
/** Display value for derived fields when the group graph cannot be read. */
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
private EffectivePermissionResolver resolverForCall;
/*******************************************************************************
** A client for a qbit API backend of the current QInstance, for code that
** is not a table action (widgets, the drift report). Honours the backend's
** configured action util class (tests swap it).
*******************************************************************************/
public static AuthentikActionUtil forBackend(String backendName) throws QException
{
if(QContext.getQInstance() == null || !(QContext.getQInstance().getBackend(backendName) instanceof APIBackendMetaData backend))
{
throw (new QException("No Authentik API backend named " + backendName));
}
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
util.setBackendMetaData(backend);
return ((AuthentikActionUtil) util);
}
/*******************************************************************************
@@ -134,8 +164,14 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
AuthentikTableSpec spec = spec(table);
runtime().getGuard().checkRead(spec);
CountOutput output = new CountOutput();
if(spec.isVirtual())
{
output.setCount(memberships(spec, countInput.getFilter()).size());
return (output);
}
AuthentikFilterMapper.MappedFilter mapped = map(spec, countInput.getFilter());
CountOutput output = new CountOutput();
if(mapped.isKeyLookup())
{
output.setCount(fetchByKeys(table, spec, mapped.keyLookup(), null).size());
@@ -143,7 +179,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
JSONObject page = getJson(listUrl(spec, mapped, 1, 1));
output.setCount(page.optJSONObject("pagination") == null ? 0 : page.getJSONObject("pagination").optInt("count", 0));
output.setCount(pagination(page).optInt("count", 0));
return (output);
}
@@ -164,6 +200,17 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
throw (new QUserFacingException(spec.getLabel() + " can only be fetched by id."));
}
if(spec.isVirtual())
{
List<JSONObject> rows = memberships(spec, new QQueryFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria(spec.getKeyFieldName(),
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, getInput.getPrimaryKey())));
if(!rows.isEmpty())
{
output.setRecord(toRecord(spec, rows.get(0), runtime().getGuard().maySeeSensitive()));
}
return (output);
}
JSONObject object = getObject(spec, getInput.getPrimaryKey());
if(object != null && isVisible(spec, object))
{
@@ -183,14 +230,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
AuthentikTableSpec spec = spec(table);
runtime().getGuard().checkRead(spec);
QueryOutput output = new QueryOutput(queryInput);
QQueryFilter filter = queryInput.getFilter();
AuthentikFilterMapper.MappedFilter mapped = map(spec, filter);
boolean sensitive = runtime().getGuard().maySeeSensitive();
QueryOutput output = new QueryOutput(queryInput);
QQueryFilter filter = queryInput.getFilter();
boolean sensitive = runtime().getGuard().maySeeSensitive();
Integer limit = filter == null ? null : filter.getLimit();
int skip = filter == null || filter.getSkip() == null ? 0 : filter.getSkip();
if(limit != null && limit <= 0)
{
return (output);
}
if(spec.isVirtual())
{
List<JSONObject> rows = memberships(spec, filter);
int added = 0;
for(int i = skip; i < rows.size() && (limit == null || added < limit); i++)
{
output.addRecord(toRecord(spec, rows.get(i), sensitive));
added++;
}
return (output);
}
AuthentikFilterMapper.MappedFilter mapped = map(spec, filter);
if(mapped.isKeyLookup())
{
List<JSONObject> objects = fetchByKeys(table, spec, mapped.keyLookup(), limit);
@@ -245,8 +310,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
offset = 0;
JSONObject pagination = response.optJSONObject("pagination");
int totalPages = pagination == null ? page : pagination.optInt("total_pages", page);
int totalPages = pagination(response).optInt("total_pages", page);
if(page >= totalPages || queryInput.getAsyncJobCallback().wasCancelRequested())
{
return (output);
@@ -300,7 +364,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
public JSONObject getObject(AuthentikTableSpec spec, Serializable key) throws QException
{
String keyText = String.valueOf(key).trim();
if(keyText.isEmpty() || !keyText.matches("[A-Za-z0-9._:-]+"))
if(keyText.isEmpty() || !keyText.matches("[A-Za-z0-9._:-]+") || keyText.contains(".."))
{
return (null);
}
@@ -354,8 +418,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
rs.add(results.getJSONObject(i));
}
JSONObject pagination = response.optJSONObject("pagination");
if(rs.size() >= maxObjects || pagination == null || page >= pagination.optInt("total_pages", page))
if(rs.size() >= maxObjects || page >= pagination(response).optInt("total_pages", page))
{
return (rs);
}
@@ -422,6 +485,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
if(AuthentikTables.USER.equals(spec.getName()))
{
checkWithinUserScope(mapped.params());
addUserScope(builder, usedNames);
}
builder.addParameter("page", String.valueOf(page));
@@ -441,6 +505,31 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
** the operator set on the same parameter wins server-side; the per-record
** check still enforces the scope.
*******************************************************************************/
private void checkWithinUserScope(List<NameValuePair> params) throws QUserFacingException
{
String pathPrefix = runtime().getConfig().getUserScopePathStartsWith();
List<String> scopeGroups = runtime().getConfig().getUserScopeGroupNames() == null ? List.of() : runtime().getConfig().getUserScopeGroupNames();
for(NameValuePair param : params)
{
boolean outside = switch(param.getName())
{
case "type" -> !List.of(AuthentikTables.DEFAULT_USER_TYPES).contains(param.getValue());
case "path", "path_startswith" -> StringUtils.hasContent(pathPrefix) && !param.getValue().startsWith(pathPrefix);
case "groups_by_name" -> !scopeGroups.isEmpty() && !scopeGroups.contains(param.getValue());
default -> false;
};
if(outside)
{
throw (new QUserFacingException("That filter (" + param.getName() + " = " + param.getValue() + ") is outside the users this app may see."));
}
}
}
/*******************************************************************************
** Scope parameters (the operator's narrower filter, checked above, wins).
*******************************************************************************/
private void addUserScope(URIBuilder builder, List<String> usedNames)
{
if(!usedNames.contains("type"))
@@ -527,6 +616,37 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
{
record.setDisplayValue("isProtected", "Protected: " + protection.reason());
}
EffectivePermissionResolver resolver = resolverForCall();
if(resolver != null)
{
List<String> groupPks = new ArrayList<>();
JSONArray groups = object.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
groupPks.add(groups.getJSONObject(i).optString("pk"));
}
record.setValue("effectivePermissions", String.join(", ", resolver.resolveGroups(groupPks).sessionPermissions()));
}
else
{
record.setDisplayValue("effectivePermissions", UNAVAILABLE);
}
}
else if(AuthentikTables.GROUP.equals(spec.getName()))
{
EffectivePermissionResolver resolver = resolverForCall();
if(resolver != null)
{
String pk = object.optString("pk");
record.setValue("isSuperuserInherited", resolverGraph.isSuperuserInherited(pk));
record.setValue("effectivePermissions", String.join(", ", resolver.resolveGroups(List.of(pk)).sessionPermissions()));
}
else
{
record.setDisplayValue("effectivePermissions", UNAVAILABLE);
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
}
}
return (record);
}
@@ -538,6 +658,310 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** The current group graph: every group with parents, attributes and member
** pks (cached for groupCacheTtlSeconds).
*******************************************************************************/
public GroupGraph groupGraph() throws QException
{
AuthentikRuntime runtime = runtime();
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
if(cache)
{
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
if(cached.isPresent())
{
return (cached.get());
}
}
List<JSONObject> groups = getAllPages("/core/groups/?include_users=false&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
if(groups.size() >= MAX_SWEEP_OBJECTS)
{
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
}
GroupGraph graph = GroupGraph.fromGroups(groups);
if(cache)
{
runtime.getGroupGraphCache().storeResult("all", graph);
}
return (graph);
}
/*******************************************************************************
** A resolver over the current group graph and the instance's permissions.
*******************************************************************************/
public EffectivePermissionResolver resolver() throws QException
{
return (new EffectivePermissionResolver(groupGraph(), runtime().getPermissionCatalog()));
}
private GroupGraph resolverGraph;
private boolean resolverFailed;
/*******************************************************************************
** The resolver for this call (built once per util instance); null when the
** group graph cannot be read, in which case derived fields stay empty.
*******************************************************************************/
private EffectivePermissionResolver resolverForCall()
{
if(resolverForCall == null && !resolverFailed)
{
try
{
resolverGraph = groupGraph();
resolverForCall = new EffectivePermissionResolver(resolverGraph, runtime().getPermissionCatalog());
}
catch(Exception e)
{
resolverFailed = true;
LOG.warn("Group graph unavailable; effective permissions shown as unavailable", logPair("error", AuthentikRedactor.redactText(String.valueOf(e.getMessage()), token())));
return (null);
}
}
return (resolverForCall);
}
/*******************************************************************************
** Rows of the virtual membership table for a filter. Criteria: groupPk
** (equals / in), groupName, userPk, username (equals), id (key lookup);
** AND only. The narrowest criterion picks the source (a group, a user, or a
** sweep of all groups); all criteria are then applied to the rows, which
** are sorted by the requested ordering (default group, then username).
*******************************************************************************/
public List<JSONObject> memberships(AuthentikTableSpec spec, QQueryFilter filter) throws QException
{
AuthentikFilterMapper.MappedFilter mapped = runtime().getFilterMapper().map(spec, filter, runtime().getGuard().maySeeSensitive());
java.util.Map<String, List<String>> wanted = new java.util.LinkedHashMap<>();
String ordering = "groupName";
for(NameValuePair param : mapped.params())
{
if(param.getName().equals("ordering"))
{
ordering = param.getValue();
}
else
{
wanted.computeIfAbsent(param.getName(), k -> new ArrayList<>()).add(param.getValue());
}
}
if(mapped.isKeyLookup())
{
for(Serializable key : mapped.keyLookup())
{
String[] parts = String.valueOf(key).split(":", 2);
if(parts.length == 2)
{
wanted.computeIfAbsent("groupPk", k -> new ArrayList<>()).add(parts[0]);
wanted.computeIfAbsent("id", k -> new ArrayList<>()).add(String.valueOf(key));
}
}
if(!wanted.containsKey("id"))
{
return (List.of());
}
}
List<JSONObject> rows = new ArrayList<>();
if(wanted.containsKey("groupPk"))
{
for(String groupPk : new java.util.LinkedHashSet<>(wanted.get("groupPk")))
{
JSONObject group = groupPk.matches("[A-Za-z0-9-]+") ? getJsonOrNull(runtime().getConfig().getApiRoot() + "/core/groups/" + groupPk + "/?include_users=true") : null;
addGroupRows(rows, group);
}
}
else if(wanted.containsKey("groupName"))
{
JSONArray results = getApiJson("/core/groups/?include_users=true&name=" + encode(wanted.get("groupName").get(0))).optJSONArray("results");
addGroupRows(rows, results == null || results.isEmpty() ? null : results.getJSONObject(0));
}
else if(wanted.containsKey("userPk") || wanted.containsKey("username"))
{
JSONObject user;
if(wanted.containsKey("userPk"))
{
String userPk = wanted.get("userPk").get(0);
user = userPk.matches("[0-9]+") ? getJsonOrNull(runtime().getConfig().getApiRoot() + "/core/users/" + userPk + "/") : null;
}
else
{
JSONArray results = getApiJson("/core/users/?include_groups=true&username=" + encode(wanted.get("username").get(0))).optJSONArray("results");
user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
}
addUserRows(rows, user);
}
else
{
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&ordering=name", MAX_SWEEP_OBJECTS);
for(JSONObject group : groups)
{
addGroupRows(rows, group);
}
if(rows.size() >= MAX_SWEEP_OBJECTS || groups.size() >= MAX_SWEEP_OBJECTS)
{
throw (new QUserFacingException("Too many memberships to list without a filter; filter by group or user."));
}
}
List<JSONObject> matching = new ArrayList<>();
for(JSONObject row : rows)
{
boolean keep = true;
for(java.util.Map.Entry<String, List<String>> entry : wanted.entrySet())
{
if(!entry.getValue().contains(String.valueOf(row.opt(entry.getKey()))))
{
keep = false;
break;
}
}
if(keep && isMemberInScope(row))
{
matching.add(row);
}
}
matching.sort(membershipComparator(ordering));
return (matching);
}
/*******************************************************************************
**
*******************************************************************************/
private static java.util.Comparator<JSONObject> membershipComparator(String ordering)
{
java.util.Comparator<JSONObject> rs = null;
for(String part : ordering.split(","))
{
boolean descending = part.startsWith("-");
String field = descending ? part.substring(1) : part;
java.util.Comparator<JSONObject> one = java.util.Comparator.comparing(o -> o.optString(field, ""), String.CASE_INSENSITIVE_ORDER);
if(descending)
{
one = one.reversed();
}
rs = rs == null ? one : rs.thenComparing(one);
}
java.util.Comparator<JSONObject> tieBreak = java.util.Comparator.comparing(o -> o.optString("groupName", "") + "\u0000" + o.optString("username", ""));
return (rs == null ? tieBreak : rs.thenComparing(tieBreak));
}
/*******************************************************************************
** Rows for every member of a group (from users_obj).
*******************************************************************************/
private static void addGroupRows(List<JSONObject> rows, JSONObject group)
{
if(group == null)
{
return;
}
JSONArray users = group.optJSONArray("users_obj");
for(int i = 0; users != null && i < users.length(); i++)
{
JSONObject user = users.getJSONObject(i);
rows.add(membershipRow(group.optString("pk"), group.optString("name"), user));
}
}
/*******************************************************************************
** Rows for every group of a user (from groups_obj).
*******************************************************************************/
private static void addUserRows(List<JSONObject> rows, JSONObject user)
{
if(user == null)
{
return;
}
JSONArray groups = user.optJSONArray("groups_obj");
for(int i = 0; groups != null && i < groups.length(); i++)
{
JSONObject group = groups.getJSONObject(i);
rows.add(membershipRow(group.optString("pk"), group.optString("name"), user));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject membershipRow(String groupPk, String groupName, JSONObject user)
{
return (new JSONObject()
.put("id", groupPk + ":" + user.optInt("pk"))
.put("groupPk", groupPk)
.put("groupName", groupName)
.put("userPk", user.optInt("pk"))
.put("username", user.optString("username"))
.put("name", user.optString("name"))
.put("email", user.optString("email"))
.put("isActive", user.optBoolean("is_active", true)));
}
/*******************************************************************************
** Membership rows carry partial users (no type or path), so with a user
** scope configured each member is checked through a (cached) user GET.
*******************************************************************************/
private boolean isMemberInScope(JSONObject row) throws QException
{
if(scopedUserPks == null)
{
scopedUserPks = new java.util.HashSet<>();
try
{
URIBuilder builder = new URIBuilder("/core/users/");
addUserScope(builder, List.of());
String query = builder.build().getRawQuery();
for(JSONObject user : getAllPages("/core/users/?" + query, MAX_SWEEP_OBJECTS))
{
if(runtime().getGuard().isUserInScope(user))
{
scopedUserPks.add(user.getInt("pk"));
}
}
}
catch(java.net.URISyntaxException e)
{
throw (new QException("Bad Authentik URL", e));
}
}
return (scopedUserPks.contains(row.optInt("userPk")));
}
private java.util.Set<Integer> scopedUserPks;
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (java.net.URLEncoder.encode(value, StandardCharsets.UTF_8));
}
/*******************************************************************************
** GET JSON through the response cache.
*******************************************************************************/
@@ -549,6 +973,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
/*******************************************************************************
** The pagination object of a list response; a list response without one
** means the API changed, which must not read as "no results".
*******************************************************************************/
static JSONObject pagination(JSONObject response) throws QException
{
JSONObject pagination = response.optJSONObject("pagination");
if(pagination == null)
{
throw (new QException("Unexpected Authentik list response (no pagination object); has the API changed?"));
}
return (pagination);
}
/*******************************************************************************
** GET an API path (relative to /api/v3) as JSON, or null on 404.
*******************************************************************************/
public JSONObject getApiJsonOrNull(String pathAndQuery) throws QException
{
return (getJsonOrNull(runtime().getConfig().getApiRoot() + pathAndQuery));
}
/*******************************************************************************
** GET JSON, or null on 404.
*******************************************************************************/
@@ -725,7 +1175,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
*******************************************************************************/
static QException mapError(HttpRequestBase request, String path, int status, String body, String token)
{
String detail = AuthentikRedactor.redactText(errorDetail(body), token);
String detail = AuthentikRedactor.redactText(errorDetail(AuthentikRedactor.redactText(body, token)), token);
if(AuthentikRedactor.isSensitivePath(path))
{
detail = "(details omitted for a sensitive endpoint)";
@@ -851,7 +1301,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
}
catch(Exception e)
{
LOG.warn("Error logging an outbound Authentik call", logPair("path", path));
LOG.warn("Error logging an outbound Authentik call", logPair("path", path), logPair("error", e.getClass().getSimpleName()));
}
}
@@ -868,7 +1318,13 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
return;
}
String expected = runtime().getConfig().getExpectedServerVersion();
fetchServerVersion().ifPresent(actual ->
Optional<String> version = fetchServerVersion();
if(version.isEmpty())
{
VERSION_CHECKED.set(false);
return;
}
version.ifPresent(actual ->
{
if(StringUtils.hasContent(expected) && !actual.startsWith(expected + ".") && !actual.equals(expected))
{
@@ -58,6 +58,17 @@ public final class AuthentikApiSurface
{
List<Operation> rs = new ArrayList<>();
rs.add(new Operation("get", "/admin/version/", Set.of()));
///////////////////////////////////////////////////////////
// virtual membership table, group graph, drift report //
///////////////////////////////////////////////////////////
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
return (rs);
}
@@ -71,6 +82,10 @@ public final class AuthentikApiSurface
List<Operation> rs = new ArrayList<>();
for(AuthentikTableSpec spec : AuthentikTables.all().values())
{
if(spec.isVirtual())
{
continue;
}
Set<String> listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering"));
for(AuthentikFieldSpec field : spec.getFields())
{
@@ -117,10 +117,14 @@ public class AuthentikFilterMapper
boolean usedSearch = false;
for(QFilterCriteria criterion : criteria)
{
if(criterion == null || criterion.getFieldName() == null)
if(criterion == null)
{
continue;
}
if(criterion.getFieldName() == null)
{
throw (new QUserFacingException("A filter criterion without a field is not supported for " + spec.getLabel() + "."));
}
if(criterion.getOtherFieldName() != null || criterion.getFieldFunction() != null)
{
@@ -148,7 +152,7 @@ public class AuthentikFilterMapper
throw (new QUserFacingException("Only one text-search criterion is supported for " + spec.getLabel() + "."));
}
usedSearch = true;
add(params, usedParams, "search", singleString(field, operator, values));
add(params, usedParams, "search", plainSearchTerm(field, singleString(field, operator, values)));
continue;
}
@@ -241,6 +245,13 @@ public class AuthentikFilterMapper
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
{
AuthentikFieldSpec field = spec.getField(orderBy.getFieldName());
if(field != null && field.getOrderingName() == null && spec.getKeyFieldName().equals(field.getName()))
{
//////////////////////////////////////////////////////////////////
// UIs often sort by the key by default: use the table's default //
//////////////////////////////////////////////////////////////////
continue;
}
if(field == null || field.getOrderingName() == null)
{
throw (new QUserFacingException(spec.getLabel() + " cannot be sorted by " + (field == null ? orderBy.getFieldName() : field.getLabel()) + "."));
@@ -339,6 +350,22 @@ public class AuthentikFilterMapper
/*******************************************************************************
** Authentik's search parameter also accepts a query language (QLSearch),
** which could reach fields the session may not filter on (attributes).
** Only plain text is passed: QL operators, quotes and keywords are refused.
*******************************************************************************/
static String plainSearchTerm(AuthentikFieldSpec field, String term) throws QUserFacingException
{
if(term.matches(".*[=~\"'()<>!\\\\].*") || term.matches("(?i).*\\s(and|or|not|in)\\s.*"))
{
throw (new QUserFacingException("Search " + field.getLabel() + " with plain text only (no operators or quotes)."));
}
return (term);
}
/*******************************************************************************
** Evaluate filter expressions (e.g. "now minus 90 days") into plain values.
*******************************************************************************/
@@ -373,7 +400,15 @@ public class AuthentikFilterMapper
if(field.getType() == QFieldType.BOOLEAN)
{
String text = String.valueOf(value).trim().toLowerCase();
return (text.equals("true") || text.equals("1") || text.equals("yes") ? "true" : "false");
if(text.equals("true") || text.equals("1") || text.equals("yes"))
{
return ("true");
}
if(text.equals("false") || text.equals("0") || text.equals("no"))
{
return ("false");
}
throw (new QUserFacingException("Not a valid yes/no value for " + field.getLabel() + ": " + value));
}
return (String.valueOf(value));
}
@@ -101,6 +101,51 @@ public final class AuthentikJson
/*******************************************************************************
** A value at a nested path (e.g. "user", "username"), as a string, or null.
** Objects and arrays are rendered as JSON text.
*******************************************************************************/
public static Function<JSONObject, Serializable> at(String... path)
{
return (o ->
{
Object current = o;
for(String key : path)
{
if(!(current instanceof JSONObject object) || !object.has(key) || object.isNull(key))
{
return (null);
}
current = object.get(key);
}
return (String.valueOf(current));
});
}
/*******************************************************************************
** An integer at a nested path, or null.
*******************************************************************************/
public static Function<JSONObject, Serializable> intAt(String... path)
{
Function<JSONObject, Serializable> text = at(path);
return (o ->
{
Serializable value = text.apply(o);
try
{
return (value == null ? null : Integer.valueOf(String.valueOf(value)));
}
catch(NumberFormatException e)
{
return (null);
}
});
}
/*******************************************************************************
** Length of an array value, or null.
*******************************************************************************/
@@ -219,6 +264,38 @@ public final class AuthentikJson
/*******************************************************************************
** Entries of a list attribute that stringList() drops: a value that is not a
** string or array, or non-string / blank array elements. Empty when fine.
*******************************************************************************/
public static List<String> malformedEntries(JSONObject object, String key)
{
List<String> rs = new ArrayList<>();
if(object == null || !object.has(key) || object.isNull(key))
{
return (rs);
}
Object value = object.get(key);
if(value instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
Object element = array.get(i);
if(!(element instanceof String s) || s.isBlank())
{
rs.add(String.valueOf(element));
}
}
}
else if(!(value instanceof String))
{
rs.add(String.valueOf(value));
}
return (rs);
}
/*******************************************************************************
** Parse an Authentik timestamp (ISO-8601 with offset or Z).
*******************************************************************************/
@@ -45,6 +45,7 @@ public class AuthentikTableSpec
private String defaultOrdering;
private List<String> recordLabelFields = new ArrayList<>();
private String requiredFilterField;
private boolean virtual;
@@ -146,6 +147,29 @@ public class AuthentikTableSpec
/*******************************************************************************
** Mark as virtual: served by custom logic in AuthentikActionUtil rather than
** by one list endpoint (its filter "params" are field names, not Authentik
** query parameters).
*******************************************************************************/
public AuthentikTableSpec withVirtual()
{
this.virtual = true;
return (this);
}
/*******************************************************************************
** Getter for virtual
*******************************************************************************/
public boolean isVirtual()
{
return (virtual);
}
/*******************************************************************************
** Get a field spec, or null.
*******************************************************************************/
@@ -20,10 +20,13 @@ package com.kingsrook.qbits.authentik.client;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.at;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.attributeList;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.bool;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.instant;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.intAt;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.integer;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.joined;
import static com.kingsrook.qbits.authentik.client.AuthentikJson.json;
@@ -41,8 +44,11 @@ import static com.kingsrook.qbits.authentik.client.AuthentikJson.string;
*******************************************************************************/
public final class AuthentikTables
{
public static final String USER = "user";
public static final String GROUP = "group";
public static final String USER = "user";
public static final String GROUP = "group";
public static final String GROUP_MEMBER = "groupMember";
public static final String SESSION = "session";
public static final String EVENT = "event";
/** User types the qbit shows by default: everything but outposts' internal service accounts. */
public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" };
@@ -53,6 +59,9 @@ public final class AuthentikTables
{
register(users());
register(groups());
register(groupMembers());
register(sessions());
register(events());
}
@@ -140,6 +149,7 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("uuid", "UUID", QFieldType.STRING, string("uuid"))
.withFilter(QCriteriaOperator.EQUALS, "uuid").withHidden());
spec.withField(new AuthentikFieldSpec("qqqAttributes", "QQQ Attributes", QFieldType.TEXT, qqqAttributes()));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("passwordChangeDate", "Password Changed", QFieldType.DATE_TIME, instant("password_change_date"))
.withSensitive());
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes"))
@@ -176,6 +186,7 @@ public final class AuthentikTables
spec.withField(new AuthentikFieldSpec("memberUsername", "Member Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "members_by_username").withFilter(QCriteriaOperator.IN, "members_by_username").withHidden());
spec.withField(new AuthentikFieldSpec("permissions", "QQQ Permissions", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_PERMISSIONS)));
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions (incl. parents)", QFieldType.TEXT, o -> null));
spec.withField(new AuthentikFieldSpec("appAccess", "App Access", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_APP_ACCESS)));
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name")));
spec.withField(new AuthentikFieldSpec("numPk", "Numeric Id", QFieldType.INTEGER, integer("num_pk")).withHidden());
@@ -184,4 +195,96 @@ public final class AuthentikTables
return (spec);
}
/*******************************************************************************
** Virtual membership table (group x user). Served by AuthentikActionUtil
** from GET /core/groups/{pk}/?include_users=true, GET /core/users/{pk}/
** (groups_obj) or a group sweep; rows are synthetic objects keyed by these
** field names. Key: "groupPk:userPk".
*******************************************************************************/
static AuthentikTableSpec groupMembers()
{
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP_MEMBER, "Group Memberships", "group_add", "/core/groups/", "id")
.withVirtual()
.withDefaultOrdering("groupName")
.withRecordLabelFields("groupName", "username");
spec.withField(new AuthentikFieldSpec("id", "Id", QFieldType.STRING, string("id")));
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, string("groupName"))
.withFilter(QCriteriaOperator.EQUALS, "groupName").withOrdering("groupName"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
.withFilter(QCriteriaOperator.EQUALS, "username").withOrdering("username"));
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")));
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email")));
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("isActive")));
spec.withField(new AuthentikFieldSpec("groupPk", "Group Id", QFieldType.STRING, string("groupPk"))
.withFilter(QCriteriaOperator.EQUALS, "groupPk").withFilter(QCriteriaOperator.IN, "groupPk"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("userPk"))
.withFilter(QCriteriaOperator.EQUALS, "userPk"));
return (spec);
}
/*******************************************************************************
** /core/authenticated_sessions/ (uuid). IPs and user agents need audit.read.
*******************************************************************************/
static AuthentikTableSpec sessions()
{
AuthentikTableSpec spec = new AuthentikTableSpec(SESSION, "Sessions", "devices", "/core/authenticated_sessions/", "uuid")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withRecordLabelFields("uuid");
spec.withField(new AuthentikFieldSpec("uuid", "Id", QFieldType.STRING, string("uuid")));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("user")));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, o -> null)
.withFilter(QCriteriaOperator.EQUALS, "user__username").withHidden());
spec.withField(new AuthentikFieldSpec("lastIp", "Last IP", QFieldType.STRING, string("last_ip"))
.withFilter(QCriteriaOperator.EQUALS, "session__last_ip"));
spec.withField(new AuthentikFieldSpec("lastUserAgent", "Last User Agent", QFieldType.TEXT, string("last_user_agent")));
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, instant("last_used")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
spec.withField(new AuthentikFieldSpec("current", "Current", QFieldType.BOOLEAN, bool("current")));
spec.withField(new AuthentikFieldSpec("geoIp", "Geo IP", QFieldType.TEXT, json("geo_ip")));
spec.withField(new AuthentikFieldSpec("asn", "ASN", QFieldType.TEXT, json("asn")));
return (spec);
}
/*******************************************************************************
** /events/events/ (uuid). Event contexts and client IPs need audit.read.
*******************************************************************************/
static AuthentikTableSpec events()
{
AuthentikTableSpec spec = new AuthentikTableSpec(EVENT, "Events", "receipt_long", "/events/events/", "pk")
.withReadPermission(IdentityPermission.AUDIT_READ)
.withDefaultOrdering("-created")
.withRecordLabelFields("action", "created");
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
spec.withField(new AuthentikFieldSpec("created", "Created", QFieldType.DATE_TIME, instant("created")).withOrdering("created"));
spec.withField(new AuthentikFieldSpec("action", "Action", QFieldType.STRING, string("action"))
.withFilter(QCriteriaOperator.EQUALS, "action").withFilter(QCriteriaOperator.IN, "actions"));
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, at("user", "username"))
.withFilter(QCriteriaOperator.EQUALS, "username"));
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, intAt("user", "pk")));
spec.withField(new AuthentikFieldSpec("app", "App", QFieldType.STRING, string("app")));
spec.withField(new AuthentikFieldSpec("clientIp", "Client IP", QFieldType.STRING, string("client_ip"))
.withFilter(QCriteriaOperator.EQUALS, "client_ip"));
spec.withField(new AuthentikFieldSpec("modelApp", "Model App", QFieldType.STRING, at("context", "model", "app"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_app"));
spec.withField(new AuthentikFieldSpec("modelName", "Model", QFieldType.STRING, at("context", "model", "model_name"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_name"));
spec.withField(new AuthentikFieldSpec("modelPk", "Model Id", QFieldType.STRING, at("context", "model", "pk"))
.withFilter(QCriteriaOperator.EQUALS, "context_model_pk"));
spec.withField(new AuthentikFieldSpec("modelLabel", "Object", QFieldType.STRING, at("context", "model", "name")));
spec.withField(new AuthentikFieldSpec("authorizedApp", "Authorized App", QFieldType.STRING, at("context", "authorized_application", "slug"))
.withFilter(QCriteriaOperator.EQUALS, "context_authorized_app"));
spec.withField(new AuthentikFieldSpec("context", "Context", QFieldType.TEXT, json("context")));
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
return (spec);
}
}
@@ -0,0 +1,461 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.drift;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The identity drift report (DESIGN.md sections 4.5, 5.1 and 8.2): checks
** that Authentik is in the shape the qbit relies on, using only the service
** account's read permissions. Each check is independent; a check that cannot
** run reports why and the rest still run.
**
** Checks:
** 1. version: server major.minor = expectedServerVersion
** 2. blueprints: every blueprint instance applied successfully
** 3. service account: exists, is a service account, has no groups, is not a
** superuser, is protected; exactly one API token (it can mint its own)
** 4. break-glass: at least one active, protected superuser exists
** 5. identity admins: at least one active, unprotected human holds both
** identity.memberships.manage and identity.permissions.manage (last-admin)
** 6. group attributes: qqq.io/permissions entries that grant nothing known;
** qqq.io/app-access entries outside applicationsInScope; superuser groups
** (or their descendants) carrying qqq.io/permissions
** 7. service-account activity: events by the service account touching a
** superuser or protected user, or a superuser group (lockout detection)
*******************************************************************************/
public class AuthentikDriftReport
{
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
private final AuthentikActionUtil client;
private final AuthentikQBitConfig config;
private final AuthentikGuard guard;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public AuthentikDriftReport(AuthentikActionUtil client, AuthentikQBitConfig config)
{
this.client = client;
this.config = config;
this.guard = new AuthentikGuard(config);
}
/*******************************************************************************
** Run every check; findings sorted by severity (errors first).
*******************************************************************************/
public List<DriftFinding> run()
{
List<DriftFinding> findings = new ArrayList<>();
runCheck(findings, "version", this::checkVersion);
runCheck(findings, "blueprints", this::checkBlueprints);
runCheck(findings, "service account", this::checkServiceAccount);
runCheck(findings, "break-glass", this::checkBreakGlass);
runCheck(findings, "identity admins", this::checkIdentityAdmins);
runCheck(findings, "group attributes", this::checkGroupAttributes);
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
findings.sort(Comparator.comparing(DriftFinding::severity));
return (findings);
}
/*******************************************************************************
** A check.
*******************************************************************************/
@FunctionalInterface
interface Check
{
/*******************************************************************************
**
*******************************************************************************/
void run(List<DriftFinding> findings) throws QException;
}
/*******************************************************************************
**
*******************************************************************************/
private void runCheck(List<DriftFinding> findings, String name, Check check)
{
try
{
check.run(findings);
}
catch(Exception e)
{
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
LOG.warn("Drift check could not run", logPair("check", name), logPair("error", AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
findings.add(DriftFinding.error(name, "-", "Check could not run, so its result is UNKNOWN: " + AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String encode(String value)
{
return (java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8));
}
/*******************************************************************************
** 1
*******************************************************************************/
void checkVersion(List<DriftFinding> findings)
{
String expected = config.getExpectedServerVersion();
String actual = client.fetchServerVersion().orElse(null);
if(expected == null || expected.isBlank())
{
findings.add(DriftFinding.info("version", actual == null ? "Authentik" : "Authentik " + actual, "No expectedServerVersion configured."));
}
else if(actual == null)
{
findings.add(DriftFinding.warning("version", "Authentik", "Server version unavailable (GET /admin/version/ failed)."));
}
else if(!(actual.equals(expected) || actual.startsWith(expected + ".")))
{
findings.add(DriftFinding.warning("version", "Authentik " + actual, "qbit-authentik was tested against " + expected + ". Run its contract tests against " + actual + " before relying on it."));
}
else
{
findings.add(DriftFinding.ok("version", "Authentik " + actual, "Matches the tested version " + expected + "."));
}
}
/*******************************************************************************
** 2
*******************************************************************************/
void checkBlueprints(List<DriftFinding> findings) throws QException
{
int failing = 0;
for(JSONObject blueprint : client.getAllPages("/managed/blueprints/", 1000))
{
String status = blueprint.optString("status", "unknown");
if(!blueprint.optBoolean("enabled", true))
{
continue;
}
if(!"successful".equals(status))
{
failing++;
findings.add(DriftFinding.error("blueprints", blueprint.optString("name"),
"Blueprint status is \"" + status + "\" (last applied " + blueprint.optString("last_applied", "never") + "). A failed blueprint rolls back entirely; check the worker logs."));
}
}
if(failing == 0)
{
findings.add(DriftFinding.ok("blueprints", "all", "Every enabled blueprint applied successfully."));
}
}
/*******************************************************************************
** 3
*******************************************************************************/
void checkServiceAccount(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
JSONArray results = client.getApiJson("/core/users/?include_groups=true&username=" + encode(username)).optJSONArray("results");
JSONObject user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
if(user == null)
{
findings.add(DriftFinding.error("service account", username, "Not found, or not visible to the API token's user. Is the token minted for this account?"));
return;
}
List<String> problems = new ArrayList<>();
if(!"service_account".equals(user.optString("type")))
{
problems.add("type is " + user.optString("type") + ", not service_account");
}
if(user.optBoolean("is_superuser", false))
{
problems.add("it is a SUPERUSER: remove it from every superuser group now");
}
JSONArray groups = user.optJSONArray("groups_obj");
if(groups != null && !groups.isEmpty())
{
problems.add("it is in " + groups.length() + " group(s); it should be in none");
}
if(!user.optBoolean("is_active", true))
{
problems.add("it is inactive");
}
JSONObject attributes = user.optJSONObject("attributes");
if(!AuthentikGuard.isMarkedProtected(user))
{
problems.add("qqq.io/protected is not true");
}
for(String problem : problems)
{
findings.add((problem.contains("SUPERUSER") ? DriftFinding.error("service account", username, problem) : DriftFinding.warning("service account", username, problem)));
}
////////////////////////////////////////////////////////////////////////////
// tokens: listing without view_token returns the caller's own tokens //
////////////////////////////////////////////////////////////////////////////
List<JSONObject> tokens = client.getAllPages("/core/tokens/?intent=api&user__username=" + encode(username), 200);
int apiOwned = 0;
if(tokens.size() >= 200)
{
findings.add(DriftFinding.error("service account", username, "200 or more API tokens for the service account; something is minting tokens."));
}
for(JSONObject token : tokens)
{
JSONObject owner = token.optJSONObject("user_obj");
if(owner != null && username.equals(owner.optString("username")) && "api".equals(token.optString("intent")))
{
apiOwned++;
}
}
if(apiOwned == 0)
{
findings.add(DriftFinding.warning("service account", username, "The service account's own API token is not visible, so extra tokens cannot be detected."));
}
else if(apiOwned > 1)
{
findings.add(DriftFinding.warning("service account", username, apiOwned + " API tokens exist for the service account; expected 1. It can mint its own tokens, so an extra one may be a leftover rotation or persistence after a compromise. Delete the ones not in Infisical."));
}
if(problems.isEmpty() && apiOwned == 1)
{
findings.add(DriftFinding.ok("service account", username, "Service account, no groups, not a superuser, protected, one API token."));
}
}
/*******************************************************************************
** 4
*******************************************************************************/
void checkBreakGlass(List<DriftFinding> findings) throws QException
{
List<JSONObject> protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200);
List<String> breakGlass = new ArrayList<>();
for(JSONObject user : protectedUsers)
{
if(user.optBoolean("is_superuser", false) && user.optBoolean("is_active", true) && AuthentikGuard.isMarkedProtected(user))
{
breakGlass.add(user.optString("username"));
}
}
if(breakGlass.isEmpty())
{
findings.add(DriftFinding.error("break-glass", "-", "No active superuser marked qqq.io/protected. Without one, a lockout can only be undone from the cluster (ak shell)."));
}
else
{
findings.add(DriftFinding.ok("break-glass", String.join(", ", breakGlass), "Active, protected superuser present."));
}
}
/*******************************************************************************
** 5
*******************************************************************************/
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
{
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
GroupGraph graph = GroupGraph.fromGroups(groups);
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
Set<Integer> humans = new HashSet<>();
for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS))
{
humans.add(human.getInt("pk"));
}
Set<Integer> checked = new HashSet<>();
List<String> admins = new ArrayList<>();
for(JSONObject group : groups)
{
JSONArray users = group.optJSONArray("users_obj");
for(int i = 0; users != null && i < users.length(); i++)
{
JSONObject user = users.getJSONObject(i);
if(!checked.add(user.getInt("pk")) || !user.optBoolean("is_active", true) || !humans.contains(user.getInt("pk")))
{
continue;
}
//////////////////////////////////////////////////////////////////////
// members are partial users (no is_superuser): derive it from the //
// groups, so superusers (who cannot be managed from QQQ) and other //
// protected accounts are not counted //
//////////////////////////////////////////////////////////////////////
boolean superuser = graph.groupsOfUser(user.getInt("pk")).stream().anyMatch(graph::isSuperuserInherited);
if(superuser || guard.protection(user).isProtected())
{
continue;
}
if(EffectivePermissionResolver.holdsAll(resolver.resolveUser(user.getInt("pk")), required))
{
admins.add(user.optString("username"));
}
}
}
if(admins.isEmpty())
{
String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators is set, which covers sign-in for now.";
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds identity.memberships.manage and identity.permissions.manage. Add an administrator to a group carrying identity.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
}
else if(admins.size() == 1)
{
findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of identity.* grants needs a second one (or the Authentik UI)."));
}
else
{
findings.add(DriftFinding.ok("identity admins", String.join(", ", admins), admins.size() + " identity administrators."));
}
}
/*******************************************************************************
** 6
*******************************************************************************/
void checkGroupAttributes(List<DriftFinding> findings) throws QException
{
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = client.resolver();
Set<String> inScope = new HashSet<>(config.getApplicationsInScope() == null ? List.of() : config.getApplicationsInScope());
int issues = 0;
for(GroupGraph.Node node : graph.nodes())
{
for(String bad : node.malformed())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
}
for(String permission : node.permissions())
{
if(resolver.expand(permission).isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
}
}
for(String app : node.appAccess())
{
if(!inScope.isEmpty() && !inScope.contains(app))
{
issues++;
findings.add(DriftFinding.info("group attributes", node.name(), "qqq.io/app-access lists \"" + app + "\", which is not in applicationsInScope."));
}
}
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
{
issues++;
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
}
}
if(issues == 0)
{
findings.add(DriftFinding.ok("group attributes", graph.nodes().size() + " groups", "Every qqq.io/* attribute is valid."));
}
}
/*******************************************************************************
** 7
*******************************************************************************/
void checkServiceAccountActivity(List<DriftFinding> findings) throws QException
{
String username = config.getEffectiveServiceAccountUsername();
List<JSONObject> events = client.getAllPages("/events/events/?ordering=-created&username=" + encode(username), 200);
GroupGraph graph = client.groupGraph();
int flagged = 0;
for(JSONObject event : events)
{
JSONObject context = event.optJSONObject("context");
JSONObject model = context == null ? null : context.optJSONObject("model");
if(model == null)
{
continue;
}
String modelName = model.optString("model_name");
String pk = model.optString("pk");
boolean sensitive = false;
if(!"user".equals(modelName) && !"group".equals(modelName))
{
continue;
}
if("model_deleted".equals(event.optString("action")))
{
sensitive = true;
}
else if("group".equals(modelName))
{
sensitive = graph.byPk(pk) == null || graph.isSuperuserInherited(pk);
}
else if(pk.matches("[0-9]+"))
{
JSONObject user = client.getApiJsonOrNull("/core/users/" + pk + "/");
sensitive = user == null || (guard.protection(user).isProtected() && !username.equals(user.optString("username")));
}
if(sensitive)
{
flagged++;
findings.add(DriftFinding.error("service-account activity", model.optString("name", pk),
"The qbit's service account changed a protected or superuser object, deleted one, or changed one that no longer exists (" + event.optString("action") + " at " + event.optString("created") + "). QQQ never does this; investigate as a possible compromise."));
}
}
if(flagged == 0)
{
findings.add(DriftFinding.ok("service-account activity", username, "No changes to protected or superuser objects in the last " + events.size() + " service-account events."));
}
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.drift;
/*******************************************************************************
** One line of the drift report.
**
** @param severity ERROR (identity management is broken or unsafe), WARNING
** (needs attention), INFO (worth knowing), OK (a check passed)
** @param check the check's short name, e.g. "blueprints"
** @param subject what the finding is about, e.g. a group name
** @param message plain-language explanation and what to do
*******************************************************************************/
public record DriftFinding(Severity severity, String check, String subject, String message)
{
/*******************************************************************************
** Severity, most severe first.
*******************************************************************************/
public enum Severity
{
ERROR, WARNING, INFO, OK
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding error(String check, String subject, String message)
{
return (new DriftFinding(Severity.ERROR, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding warning(String check, String subject, String message)
{
return (new DriftFinding(Severity.WARNING, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding info(String check, String subject, String message)
{
return (new DriftFinding(Severity.INFO, check, subject, message));
}
/*******************************************************************************
**
*******************************************************************************/
public static DriftFinding ok(String check, String subject, String message)
{
return (new DriftFinding(Severity.OK, check, subject, message));
}
}
@@ -67,6 +67,12 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
app.withChild(table);
}
}
String driftReport = config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME);
if(produced.get(com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData.class, driftReport) != null)
{
app.withWidgets(java.util.List.of(driftReport));
}
}
return (app);
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
/*******************************************************************************
** The "Identity Drift Report" widget: a standard TABLE widget (generic UI, no
** frontend work) listing the AuthentikDriftReport findings. Needs
** identity.admin. The renderer finds the qbit through the "backendName"
** default value.
*******************************************************************************/
public class AuthentikDriftReportWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
{
public static final String NAME = "driftReport";
public static final String BACKEND_NAME_KEY = "backendName";
/*******************************************************************************
**
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
AuthentikQBitConfig config = getQBitConfig();
return (new QWidgetMetaData()
.withName(config.applyPrefix(NAME))
.withLabel("Identity Drift Report")
.withType(WidgetType.TABLE.getType())
.withGridColumns(12)
.withIsCard(true)
.withCodeReference(new QCodeReference(AuthentikDriftReportWidgetRenderer.class))
.withPermissionRules(IdentityPermission.ADMIN.rules())
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
}
/*******************************************************************************
** After the tables, before the app.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (500);
}
}
@@ -0,0 +1,86 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
/*******************************************************************************
** Renders the drift report as a TableData. Re-checks identity.admin itself
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
*******************************************************************************/
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
{
throw (new QPermissionDeniedException("You do not have permission to view the identity drift report."));
}
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikDriftReportWidgetProducer.BACKEND_NAME_KEY));
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
AuthentikActionUtil client = AuthentikActionUtil.forBackend(backendName);
List<DriftFinding> findings = new AuthentikDriftReport(client, runtime.getConfig()).run();
List<Map<String, Object>> rows = new ArrayList<>();
for(DriftFinding finding : findings)
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("severity", finding.severity().name());
row.put("check", finding.check());
row.put("subject", finding.subject());
row.put("message", finding.message());
rows.add(row);
}
List<TableData.Column> columns = List.of(
new TableData.Column("default", "Severity", "severity", "10%", "left"),
new TableData.Column("default", "Check", "check", "15%", "left"),
new TableData.Column("default", "Subject", "subject", "20%", "left"),
new TableData.Column("default", "Finding", "message", "55%", "left"));
TableData tableData = new TableData("Identity Drift Report", columns, rows)
.withRowsPerPage(25)
.withNoRowsFoundHTML("No findings.");
return (new RenderWidgetOutput(tableData));
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikEvent: /events/events/ (needs identity.audit.read).
*******************************************************************************/
public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.EVENT);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikGroupMember: virtual membership table (group x user).
*******************************************************************************/
public class AuthentikGroupMemberTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.GROUP_MEMBER);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.metadata;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
/*******************************************************************************
** authentikSession: /core/authenticated_sessions/ (needs identity.audit.read).
*******************************************************************************/
public class AuthentikSessionTableProducer extends AbstractAuthentikTableProducer
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected String getSpecName()
{
return (AuthentikTables.SESSION);
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.AuthentikRuntime;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
import org.json.JSONArray;
import org.json.JSONObject;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Sign-in permissions from Authentik groups (DESIGN.md section 5.6). Use as
** (or extend in) the host's OAuth2 authentication customizer.
**
** 1. Group names come from the token's "groups" claim (kof22's profile
** mapping emits direct groups only).
** 2. Each group is resolved with its ancestors through the cached group
** graph; the union of qqq.io/permissions becomes session permissions,
** expanded and checked against the instance's permission catalog.
** 3. Static fallbacks (withGroupPermissions) and bootstrapOperators are added.
** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap
** operators) apply: it never grants everything.
**
** The host keeps its own user/full-name handling; this only adds permissions.
** QQQ instantiates customizers with no arguments, so the API backend is found
** by name (default "authentikApi"; override getApiBackendName for a prefix).
*******************************************************************************/
public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCustomizerInterface
{
private static final QLogger LOG = QLogger.getLogger(AuthentikGroupPermissionMapper.class);
public static final String CLAIMS_CONTEXT_KEY = "jwtPayloadJsonObject";
private final Map<String, List<String>> staticGroupPermissions = new LinkedHashMap<>();
/*******************************************************************************
** The qbit's API backend name (default: the unprefixed-namespace default).
*******************************************************************************/
protected String getApiBackendName()
{
return ("authentikApi");
}
/*******************************************************************************
** Static fallback: always grant these permissions to members of a group
** (useful in tests and dev, and as the floor when Authentik is down).
*******************************************************************************/
public AuthentikGroupPermissionMapper withGroupPermissions(String groupName, List<String> permissions)
{
staticGroupPermissions.put(groupName, new ArrayList<>(permissions));
return (this);
}
/*******************************************************************************
** Add permissions from the session's token claims.
*******************************************************************************/
@Override
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
{
JSONObject claims = context == null ? null : asClaims(context.get(CLAIMS_CONTEXT_KEY));
if(claims == null)
{
return;
}
List<String> groups = groupNames(claims);
String email = claims.optString("email", "").trim().toLowerCase();
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, email));
if(qSession.getPermissions() != null)
{
permissions.addAll(qSession.getPermissions());
}
qSession.setPermissions(permissions);
}
/*******************************************************************************
** The permissions for a user's group names and email.
*******************************************************************************/
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String email)
{
Set<String> rs = new TreeSet<>();
for(String group : groupNames)
{
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
}
AuthentikRuntime runtime = runtime();
if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email))
{
LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email));
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
}
try
{
AuthentikActionUtil client = client(qInstance);
GroupGraph graph = client.groupGraph();
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
List<String> pks = new ArrayList<>();
for(String name : groupNames)
{
GroupGraph.Node node = graph.byName(name);
if(node != null)
{
pks.add(node.pk());
}
}
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
if(!resolution.unknown().isEmpty())
{
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
}
rs.addAll(resolution.sessionPermissions());
}
catch(Exception e)
{
LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken())));
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private AuthentikRuntime runtime()
{
try
{
return (AuthentikRuntime.forBackend(getApiBackendName()));
}
catch(IllegalStateException e)
{
return (null);
}
}
/*******************************************************************************
** A client bound to the qbit backend of the given instance.
*******************************************************************************/
protected AuthentikActionUtil client(QInstance qInstance)
{
if(qInstance == null || !(qInstance.getBackend(getApiBackendName()) instanceof APIBackendMetaData backend))
{
throw (new IllegalStateException("No Authentik API backend named " + getApiBackendName()));
}
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
util.setBackendMetaData(backend);
return ((AuthentikActionUtil) util);
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject asClaims(Object value)
{
if(value instanceof JSONObject object)
{
return (object);
}
if(value instanceof Map<?, ?> map)
{
return (new JSONObject(map));
}
return (null);
}
/*******************************************************************************
** Group names from the "groups" claim (array, or a single string).
*******************************************************************************/
static List<String> groupNames(JSONObject claims)
{
List<String> rs = new ArrayList<>();
Object groups = claims.opt("groups");
if(groups instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
String name = array.optString(i, "").trim();
if(!name.isEmpty())
{
rs.add(name);
}
}
}
else if(groups instanceof String name && !name.isBlank())
{
rs.add(name.trim());
}
return (rs);
}
}
@@ -0,0 +1,189 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
/*******************************************************************************
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
**
** Rules:
** - A group confers the union of qqq.io/permissions on itself and every
** ancestor ("inherited parents").
** - "identity.*" expands to every identity permission; an identity short
** name ("identity.users.read") becomes its session form
** ("identity.users.read.hasAccess").
** - Any other name ending in ".*" expands against the catalog (prefix match);
** with no catalog it is dropped, never granted as a literal wildcard.
** - Names not in the catalog are reported as unknown and NOT granted (when a
** catalog is given); with no catalog they pass through unchanged.
*******************************************************************************/
public class EffectivePermissionResolver
{
private final GroupGraph graph;
private final Set<String> catalog;
/*******************************************************************************
** Result for a set of groups.
*******************************************************************************/
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
{
}
/*******************************************************************************
** Constructor.
**
** @param graph the group hierarchy
** @param catalog QQQ permission names that may be granted (session form,
** e.g. from PermissionsHelper.getAllAvailablePermissionNames);
** null to pass non-identity names through unchecked
*******************************************************************************/
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
{
this.graph = graph;
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
if(this.catalog != null)
{
for(IdentityPermission permission : IdentityPermission.values())
{
this.catalog.add(permission.getSessionPermission());
}
}
}
/*******************************************************************************
** Effective permissions of a set of groups (e.g. a user's direct groups).
*******************************************************************************/
public Resolution resolveGroups(Collection<String> groupPks)
{
Set<String> permissions = new TreeSet<>();
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
Set<String> unknown = new TreeSet<>();
Set<String> appAccess = new TreeSet<>();
Set<String> visited = new LinkedHashSet<>();
for(String pk : groupPks)
{
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
{
if(!visited.add(node.pk()))
{
continue;
}
appAccess.addAll(node.appAccess());
for(String raw : node.permissions())
{
Set<String> expanded = expand(raw);
if(expanded.isEmpty())
{
unknown.add(raw);
}
for(String permission : expanded)
{
permissions.add(permission);
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
}
}
}
}
return (new Resolution(permissions, grantedBy, unknown, appAccess));
}
/*******************************************************************************
** Effective permissions of a user from the graph's memberships.
*******************************************************************************/
public Resolution resolveUser(int userPk)
{
return (resolveGroups(graph.groupsOfUser(userPk)));
}
/*******************************************************************************
** Expand one attribute entry into session permissions (empty when it
** grants nothing known).
*******************************************************************************/
public Set<String> expand(String raw)
{
Set<String> rs = new TreeSet<>();
if(raw == null || raw.isBlank())
{
return (rs);
}
String name = raw.trim();
if(name.equals(IdentityPermission.WILDCARD))
{
for(IdentityPermission permission : IdentityPermission.values())
{
rs.add(permission.getSessionPermission());
}
return (rs);
}
if(IdentityPermission.fromName(name).isPresent())
{
rs.add(IdentityPermission.fromName(name).get().getSessionPermission());
return (rs);
}
if(name.endsWith(".*"))
{
String prefix = name.substring(0, name.length() - 1);
if(catalog != null)
{
catalog.stream().filter(c -> c.startsWith(prefix)).forEach(rs::add);
}
return (rs);
}
if(catalog == null || catalog.contains(name))
{
rs.add(name);
}
return (rs);
}
/*******************************************************************************
** Whether a resolution holds every given identity permission.
*******************************************************************************/
public static boolean holdsAll(Resolution resolution, List<IdentityPermission> required)
{
return (required.stream().allMatch(p -> resolution.sessionPermissions().contains(p.getSessionPermission())));
}
}
@@ -0,0 +1,286 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.authentik.client.AuthentikJson;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Snapshot of Authentik's group hierarchy: groups by pk with their parents
** (many-to-many since 2025.12), own superuser flag, qqq.io/* attributes and
** member pks. Ancestry closures are computed on demand and cycle-safe.
*******************************************************************************/
public class GroupGraph
{
/*******************************************************************************
** One group.
*******************************************************************************/
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
{
}
private final Map<String, Node> byPk = new LinkedHashMap<>();
private final Map<String, Node> byName = new LinkedHashMap<>();
private final Map<String, Set<String>> ancestors = new java.util.concurrent.ConcurrentHashMap<>();
/*******************************************************************************
** Build from Authentik group objects (as returned by /core/groups/).
*******************************************************************************/
public static GroupGraph fromGroups(Collection<JSONObject> groups)
{
GroupGraph graph = new GroupGraph();
for(JSONObject group : groups)
{
graph.add(new Node(
group.getString("pk"),
group.optString("name", ""),
group.optBoolean("is_superuser", false),
strings(group.optJSONArray("parents")),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
ints(group.optJSONArray("users")),
malformed(group.optJSONObject("attributes"))));
}
return (graph);
}
/*******************************************************************************
** Add a node.
*******************************************************************************/
public GroupGraph add(Node node)
{
byPk.put(node.pk(), node);
byName.put(node.name(), node);
ancestors.clear();
return (this);
}
/*******************************************************************************
** All groups.
*******************************************************************************/
public Collection<Node> nodes()
{
return (byPk.values());
}
/*******************************************************************************
** A group by pk, or null.
*******************************************************************************/
public Node byPk(String pk)
{
return (byPk.get(pk));
}
/*******************************************************************************
** A group by name, or null.
*******************************************************************************/
public Node byName(String name)
{
return (byName.get(name));
}
/*******************************************************************************
** All ancestors of a group (parents, their parents, ...), not including the
** group itself. Unknown parents are ignored; cycles terminate.
*******************************************************************************/
public Set<String> ancestorPks(String pk)
{
Set<String> cached = ancestors.get(pk);
if(cached != null)
{
return (cached);
}
Set<String> seen = new LinkedHashSet<>();
Deque<String> queue = new ArrayDeque<>();
Node start = byPk.get(pk);
if(start != null)
{
queue.addAll(start.parentPks());
}
while(!queue.isEmpty())
{
String next = queue.removeFirst();
if(next.equals(pk) || !seen.add(next))
{
continue;
}
Node parent = byPk.get(next);
if(parent != null)
{
queue.addAll(parent.parentPks());
}
}
Set<String> immutable = java.util.Collections.unmodifiableSet(seen);
ancestors.put(pk, immutable);
return (immutable);
}
/*******************************************************************************
** The group and all of its ancestors.
*******************************************************************************/
public List<Node> selfAndAncestors(String pk)
{
List<Node> rs = new ArrayList<>();
Node self = byPk.get(pk);
if(self != null)
{
rs.add(self);
}
for(String ancestor : ancestorPks(pk))
{
Node node = byPk.get(ancestor);
if(node != null)
{
rs.add(node);
}
}
return (rs);
}
/*******************************************************************************
** Whether a group grants superuser status, itself or through any ancestor
** (Authentik's User.all_groups() includes ancestors).
*******************************************************************************/
public boolean isSuperuserInherited(String pk)
{
return (selfAndAncestors(pk).stream().anyMatch(Node::ownSuperuser));
}
/*******************************************************************************
** pks of the groups a user is a direct member of.
*******************************************************************************/
public List<String> groupsOfUser(int userPk)
{
List<String> rs = new ArrayList<>();
for(Node node : byPk.values())
{
if(node.memberPks().contains(userPk))
{
rs.add(node.pk());
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> strings(JSONArray array)
{
List<String> rs = new ArrayList<>();
if(array != null)
{
for(int i = 0; i < array.length(); i++)
{
Object value = array.get(i);
rs.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<Integer> ints(JSONArray array)
{
List<Integer> rs = new ArrayList<>();
if(array != null)
{
for(int i = 0; i < array.length(); i++)
{
Object value = array.get(i);
if(value instanceof Number number)
{
rs.add(number.intValue());
}
else if(value instanceof JSONObject object && object.has("pk"))
{
rs.add(object.getInt("pk"));
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> malformed(JSONObject attributes)
{
List<String> rs = new ArrayList<>();
for(String key : List.of(AuthentikJson.ATTR_PERMISSIONS, AuthentikJson.ATTR_APP_ACCESS))
{
for(String bad : AuthentikJson.malformedEntries(attributes, key))
{
rs.add(key + ": " + bad);
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> nonNull(List<String> list)
{
return (list == null ? List.of() : list);
}
}
@@ -176,8 +176,7 @@ public class AuthentikGuard
{
return (ProtectionResult.of("Authentik superuser"));
}
JSONObject attributes = user.optJSONObject("attributes");
if(attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)))
if(isMarkedProtected(user))
{
return (ProtectionResult.of("marked qqq.io/protected"));
}
@@ -197,6 +196,17 @@ public class AuthentikGuard
/*******************************************************************************
** Whether a user carries qqq.io/protected: true (boolean or "true").
*******************************************************************************/
public static boolean isMarkedProtected(JSONObject user)
{
JSONObject attributes = user == null ? null : user.optJSONObject("attributes");
return (attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)));
}
/*******************************************************************************
** Whether the session holds an identity permission.
*******************************************************************************/
@@ -65,7 +65,10 @@ class AuthentikQBitProducerTest
QAppMetaData app = qInstance.getApp("authentikAdmin");
assertThat(app.getLabel()).isEqualTo("Identity");
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup");
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup", "authentikGroupMember", "authentikSession", "authentikEvent");
assertThat(app.getWidgets()).containsExactly("authentikDriftReport");
assertThat(qInstance.getTable("authentikSession").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.audit.read");
assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.admin");
assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission());
assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:authentik");
@@ -0,0 +1,179 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
import com.kingsrook.qbits.authentik.client.AuthentikTables;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Regression tests for the phase 0/1 review findings.
*******************************************************************************/
class ReviewFixesTest
{
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
** Search is plain text only (QLSearch could reach sensitive fields).
*******************************************************************************/
@Test
void searchIsPlainTextOnly() throws QException
{
for(String term : List.of("attributes.x = \"y\"", "a ~ b", "(x)", "it's", "a and b", "x OR y", "a\\b"))
{
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, term)), true))
.as(term).isInstanceOf(QUserFacingException.class).hasMessageContaining("plain text");
}
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe-1")), false).params().get(0).getValue())
.isEqualTo("read.probe-1");
}
/*******************************************************************************
** Sorting by the key falls back to the default; booleans are strict; a
** criterion without a field is refused.
*******************************************************************************/
@Test
void orderingBooleansAndNullFields() throws QException
{
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter().withOrderBy(new QFilterOrderBy("pk", false)), false).params().get(0).getValue()).isEqualTo("username");
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "maybe")), false))
.isInstanceOf(QUserFacingException.class);
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria(null, QCriteriaOperator.EQUALS, "x")), false))
.isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** Config: https unless loopback or explicitly allowed; groupScope refused.
*******************************************************************************/
@Test
void configChecks()
{
List<String> errors = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.example.test").validate(new QInstance(), errors);
assertThat(errors).anyMatch(e -> e.contains("https"));
for(String ok : List.of("http://localhost:9000", "http://127.0.0.1:9000"))
{
List<String> none = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl(ok).validate(new QInstance(), none);
assertThat(none).as(ok).isEmpty();
}
List<String> allowed = new ArrayList<>();
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.internal").withAllowInsecureHttp(true).validate(new QInstance(), allowed);
assertThat(allowed).isEmpty();
List<String> scope = new ArrayList<>();
AuthentikTestSupport.unitConfig().withGroupScopeNamePrefix("kof").validate(new QInstance(), scope);
assertThat(scope).anyMatch(e -> e.contains("groupScopeNamePrefix"));
}
/*******************************************************************************
** Malformed qqq.io/* attributes are kept as findings, not silently dropped.
*******************************************************************************/
@Test
void malformedAttributes()
{
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "g").put("name", "g").put("parents", new JSONArray())
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("cms.edit").put(5).put(new JSONObject())).put("qqq.io/app-access", 12))));
assertThat(graph.byPk("g").permissions()).containsExactly("cms.edit");
assertThat(graph.byPk("g").malformed()).hasSize(3);
}
/*******************************************************************************
** Through the action util: limit 0, ".." keys, scope widening refused,
** malformed attributes reported by the drift report.
*******************************************************************************/
@Test
void actionUtilAndDrift() throws QException
{
FixtureActionUtil.reset();
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withUserScopePathStartsWith("users");
QInstance qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
FixtureRouter router = new FixtureRouter().install();
QueryInput zero = new QueryInput(config.applyPrefix("user"));
zero.setFilter(new QQueryFilter().withLimit(0));
assertThat(new QueryAction().execute(zero).getRecords()).isEmpty();
assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("group")).withPrimaryKey(".."))).isNull();
assertThat(FixtureActionUtil.REQUESTS.stream().noneMatch(r -> r.getURI().getPath().contains(".."))).isTrue();
QueryInput widen = new QueryInput(config.applyPrefix("user"));
widen.setFilter(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.EQUALS, "internal_service_account")));
assertThatThrownBy(() -> new QueryAction().execute(widen)).hasMessageContaining("outside the users");
QueryInput otherPath = new QueryInput(config.applyPrefix("user"));
otherPath.setFilter(new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "service-accounts")));
assertThatThrownBy(() -> new QueryAction().execute(otherPath)).hasMessageContaining("outside the users");
router.groups.get(0).put("attributes", new JSONObject().put("qqq.io/permissions", 42));
router.refreshGraph();
List<DriftFinding> findings = new AuthentikDriftReport(com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().startsWith("Malformed entry"));
}
}
@@ -33,10 +33,16 @@ import org.apache.http.message.BasicHeader;
*******************************************************************************/
public class FixtureActionUtil extends AuthentikActionUtil
{
public static final String EMPTY_PAGE = "{\"pagination\":{\"count\":0,\"current\":1,\"total_pages\":1},\"results\":[]}";
public static final List<HttpRequestBase> REQUESTS = new ArrayList<>();
public static Function<HttpRequestBase, QHttpResponse> handler = r -> response(404, "{\"detail\":\"Not found.\"}");
/** Body served for the group-graph sweep (GET /core/groups/?include_users=false&include_parents=false...). */
public static String groupGraphBody = EMPTY_PAGE;
/*******************************************************************************
@@ -46,6 +52,7 @@ public class FixtureActionUtil extends AuthentikActionUtil
{
REQUESTS.clear();
handler = r -> response(404, "{\"detail\":\"Not found.\"}");
groupGraphBody = EMPTY_PAGE;
AuthentikActionUtil.resetVersionCheck();
}
@@ -62,6 +69,10 @@ public class FixtureActionUtil extends AuthentikActionUtil
{
return (response(200, AuthentikTestSupport.resource("fixtures/2026.8.3/admin-version.json")));
}
if(request.getURI().getPath().endsWith("/core/groups/") && String.valueOf(request.getURI().getRawQuery()).contains("include_parents=false"))
{
return (response(200, groupGraphBody));
}
return (handler.apply(request));
}
@@ -0,0 +1,202 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** A FixtureActionUtil handler that answers like a small Authentik built from
** the recorded 2026.8.3 fixtures: users (with groups), groups (with users),
** tokens, blueprints, events and sessions, honouring the query parameters
** the qbit sends. Tests mutate the JSON to build scenarios.
*******************************************************************************/
public class FixtureRouter implements Function<HttpRequestBase, QHttpResponse>
{
public final List<JSONObject> users = results("users-list");
public final List<JSONObject> groups = results("groups-list-with-users");
public final List<JSONObject> tokens = results("tokens-list");
public final List<JSONObject> blueprints = results("blueprints-list");
public final List<JSONObject> events = results("events-by-service-account");
public final List<JSONObject> sessions = results("sessions-list");
/*******************************************************************************
** Results of a recorded list fixture (mutable copies).
*******************************************************************************/
public static List<JSONObject> results(String fixture)
{
JSONArray array = new JSONObject(FixtureActionUtil.fixture(fixture)).getJSONArray("results");
List<JSONObject> rs = new ArrayList<>();
for(int i = 0; i < array.length(); i++)
{
rs.add(new JSONObject(array.getJSONObject(i).toString()));
}
return (rs);
}
/*******************************************************************************
** Install as the FixtureActionUtil handler (and as the group graph).
*******************************************************************************/
public FixtureRouter install()
{
FixtureActionUtil.handler = this;
FixtureActionUtil.groupGraphBody = page(groups).toString();
return (this);
}
/*******************************************************************************
** Refresh the group-graph body after mutating groups.
*******************************************************************************/
public void refreshGraph()
{
FixtureActionUtil.groupGraphBody = page(groups).toString();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public QHttpResponse apply(HttpRequestBase request)
{
String path = request.getURI().getPath().replaceFirst("^/api/v3", "");
if(path.equals("/core/users/"))
{
return (ok(page(filter(users, request, "username", "username"))));
}
if(path.matches("/core/users/[0-9]+/"))
{
int pk = Integer.parseInt(path.split("/")[3]);
return (users.stream().filter(u -> u.getInt("pk") == pk).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
}
if(path.equals("/core/groups/"))
{
return (ok(page(filter(groups, request, "name", "name"))));
}
if(path.matches("/core/groups/[^/]+/"))
{
String pk = path.split("/")[3];
return (groups.stream().filter(g -> g.getString("pk").equals(pk)).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
}
if(path.equals("/core/tokens/"))
{
return (ok(page(tokens)));
}
if(path.equals("/managed/blueprints/"))
{
return (ok(page(blueprints)));
}
if(path.equals("/events/events/"))
{
return (ok(page(filter(events, request, "username", null))));
}
if(path.equals("/core/authenticated_sessions/"))
{
return (ok(page(sessions)));
}
return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}"));
}
/*******************************************************************************
** Apply a simple equality query parameter, plus the users attributes filter.
*******************************************************************************/
private static List<JSONObject> filter(List<JSONObject> objects, HttpRequestBase request, String param, String property)
{
String value = param(request, param);
String attrs = param(request, "attributes");
Predicate<JSONObject> keep = o -> true;
if(value != null && property != null)
{
keep = keep.and(o -> value.equals(o.optString(property)));
}
if(attrs != null)
{
JSONObject wanted = new JSONObject(attrs);
keep = keep.and(o ->
{
JSONObject attributes = o.optJSONObject("attributes");
return (attributes != null && wanted.keySet().stream().allMatch(k -> String.valueOf(wanted.get(k)).equals(String.valueOf(attributes.opt(k)))));
});
}
return (objects.stream().filter(keep).toList());
}
/*******************************************************************************
**
*******************************************************************************/
private static String param(HttpRequestBase request, String name)
{
String query = request.getURI().getRawQuery();
if(query == null)
{
return (null);
}
for(String pair : query.split("&"))
{
String[] parts = pair.split("=", 2);
if(parts[0].equals(name) && parts.length == 2)
{
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
public static JSONObject page(List<JSONObject> objects)
{
return (new JSONObject()
.put("pagination", new JSONObject().put("count", objects.size()).put("current", 1).put("total_pages", 1))
.put("results", new JSONArray(objects)));
}
/*******************************************************************************
**
*******************************************************************************/
private static QHttpResponse ok(JSONObject body)
{
return (FixtureActionUtil.response(200, body.toString()));
}
}
@@ -0,0 +1,216 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.client;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Phase 1 tables over the recorded fixtures: the virtual membership table,
** sessions, events, and the graph-derived fields on users and groups.
*******************************************************************************/
class Phase1TablesTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
private FixtureRouter router;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
router = new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(config.applyPrefix(table));
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
**
*******************************************************************************/
private JSONObject group(String name)
{
return (router.groups.stream().filter(g -> g.getString("name").equals(name)).findFirst().orElseThrow());
}
/*******************************************************************************
** Memberships: by group name / pk, by user, by key, sweep, ordering, paging.
*******************************************************************************/
@Test
void memberships() throws QException
{
JSONObject child = group("phase1-child-of-identity-admins");
JSONObject member = child.getJSONArray("users_obj").getJSONObject(0);
int userPk = member.getInt("pk");
String groupPk = child.getString("pk");
////////////////////////////////////////////////////////////////////////////
// the recorded users list predates this user: add it, with its groups //
////////////////////////////////////////////////////////////////////////////
router.users.add(new JSONObject(member.toString()).put("type", "internal").put("path", "users")
.put("groups_obj", new org.json.JSONArray().put(new JSONObject().put("pk", groupPk).put("name", child.getString("name")))));
List<QRecord> byName = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
assertThat(byName).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
assertThat(byName.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.IN, List.of(groupPk, "missing"))))).hasSize(1);
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)))).extracting(r -> r.getValueString("groupName")).contains("phase1-child-of-identity-admins");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member")))).isNotEmpty();
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "nobody")))).isEmpty();
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, 999999)))).isEmpty();
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey("no-colon"))).isNull();
List<QRecord> all = query("groupMember", new QQueryFilter());
int count = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount();
assertThat(all).hasSize(count).isNotEmpty();
assertThat(all).extracting(r -> r.getValueString("groupName")).isSortedAccordingTo(String.CASE_INSENSITIVE_ORDER);
List<QRecord> byUserDesc = query("groupMember", new QQueryFilter().withOrderBy(new QFilterOrderBy("username", false)).withLimit(2).withSkip(1));
assertThat(byUserDesc).hasSize(2);
assertThatThrownBy(() -> query("groupMember", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, "x")))).isInstanceOf(QUserFacingException.class);
}
/*******************************************************************************
** With a user scope, membership rows are checked member by member.
*******************************************************************************/
@Test
void membershipsHonourUserScope() throws QException
{
config.withUserScopePathStartsWith("nowhere/");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")))).isEmpty();
}
/*******************************************************************************
** Graph-derived fields: effective permissions (inherited) and inherited
** superuser.
*******************************************************************************/
@Test
void derivedFields() throws QException
{
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/groups/")
? FixtureActionUtil.response(200, FixtureRouter.page(router.groups).toString())
: router.apply(request);
List<QRecord> groups = query("group", new QQueryFilter());
QRecord child = groups.stream().filter(g -> "phase1-child-of-identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess");
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
QRecord admins = groups.stream().filter(g -> "authentik Admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
JSONObject member = router.users.stream().filter(u -> u.getString("username").equals("read.probe1")).findFirst().orElseThrow();
FixtureActionUtil.handler = router;
QRecord user = new GetAction().executeForRecord(new GetInput(config.applyPrefix("user")).withPrimaryKey(member.getInt("pk")));
assertThat(user.getValue("effectivePermissions")).isNotNull();
}
/*******************************************************************************
** Sessions and events need identity.audit.read and map recorded rows.
*******************************************************************************/
@Test
void sessionsAndEvents() throws QException
{
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/events/events/")
? FixtureActionUtil.response(200, FixtureActionUtil.fixture("events-list"))
: router.apply(request);
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
assertThat(sessions).isNotEmpty();
assertThat(sessions.get(0).getValueString("uuid")).isNotBlank();
assertThat(sessions.get(0).getValue("lastUsed")).isInstanceOf(java.time.Instant.class);
List<QRecord> events = query("event", new QQueryFilter(new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(5));
assertThat(events).isNotEmpty().allMatch(e -> "group".equals(e.getValueString("modelName")));
assertThat(events.get(0).getValueString("modelApp")).isEqualTo("authentik_core");
assertThat(events.get(0).getValue("created")).isInstanceOf(java.time.Instant.class);
}
}
@@ -48,6 +48,7 @@ import org.testcontainers.utility.MountableFile;
public final class AuthentikTestStack
{
public static final String BOOTSTRAP_TOKEN = "contract-test-bootstrap-token-0123456789";
public static final String BOOTSTRAP_PASSWORD = "contract-test-password-123";
public static final String SERVICE_ACCOUNT = "qqq-kof22-admin-idadmin";
public static final String BREAK_GLASS = "kof22-breakglass";
public static final String SUPERUSER_GROUP = "authentik Admins";
@@ -116,7 +117,7 @@ public final class AuthentikTestStack
env.put("AUTHENTIK_POSTGRESQL__PASSWORD", "authentik-test");
env.put("AUTHENTIK_POSTGRESQL__NAME", "authentik");
env.put("AUTHENTIK_BOOTSTRAP_TOKEN", BOOTSTRAP_TOKEN);
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", "contract-test-password-123");
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", BOOTSTRAP_PASSWORD);
env.put("AUTHENTIK_BOOTSTRAP_EMAIL", "akadmin@example.test");
env.put("AUTHENTIK_EMAIL__HOST", "mailpit");
env.put("AUTHENTIK_EMAIL__PORT", "1025");
@@ -0,0 +1,93 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.contract;
import java.net.CookieManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.json.JSONObject;
/*******************************************************************************
** Signs a user in through Authentik's default authentication flow (flow
** executor API), creating a real AuthenticatedSession and login event in the
** disposable test instance. Test-only.
*******************************************************************************/
public final class FlowLogin
{
private static final String FLOW = "default-authentication-flow";
/*******************************************************************************
** Not instantiable.
*******************************************************************************/
private FlowLogin()
{
}
/*******************************************************************************
** Run identification + password; returns the final challenge component
** (e.g. "xak-flow-redirect" on success).
*******************************************************************************/
public static String login(String baseUrl, String username, String password) throws Exception
{
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.cookieHandler(new CookieManager())
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
String executor = baseUrl + "/api/v3/flows/executor/" + FLOW + "/?query=";
JSONObject challenge = send(client, HttpRequest.newBuilder(URI.create(executor)).GET());
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-identification").put("uid_field", username)));
if("ak-stage-password".equals(challenge.optString("component")))
{
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-password").put("password", password)));
}
return (challenge.optString("component"));
}
/*******************************************************************************
**
*******************************************************************************/
private static HttpRequest.Builder post(String url, JSONObject body)
{
return (HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())));
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject send(HttpClient client, HttpRequest.Builder request) throws Exception
{
HttpResponse<String> response = client.send(request.header("Accept", "application/json").timeout(Duration.ofSeconds(30)).build(), HttpResponse.BodyHandlers.ofString());
return (response.body() == null || response.body().isBlank() || !response.body().trim().startsWith("{") ? new JSONObject() : new JSONObject(response.body()));
}
}
@@ -0,0 +1,242 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.contract;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
import com.kingsrook.qbits.authentik.drift.DriftFinding;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Phase 1 read MVP against Authentik 2026.8.3, as the service account with
** the phase 0/1 role: memberships (virtual table), sessions and events
** (identity.audit.read), effective permissions and inherited superuser on
** groups, and the drift report. Runs after SuperuserFloorIT, whose lockout
** probes are exactly what the drift report must flag. Records fixtures.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Order(70)
class Phase1ReadContractIT
{
private static AuthentikTestStack stack;
private QInstance qInstance;
private AuthentikQBitConfig config;
/*******************************************************************************
** A member of a child of identity-admins (inherited identity.*), and a real
** session for akadmin.
*******************************************************************************/
@BeforeAll
static void beforeAll() throws Exception
{
stack = AuthentikTestStack.get();
TestApi admin = stack.admin();
admin.post("/core/groups/", new JSONObject().put("name", "phase1-child-of-identity-admins").put("parents", new JSONArray().put(admin.groupPk("identity-admins"))));
admin.post("/core/users/", new JSONObject().put("username", "phase1.member").put("name", "Phase One").put("email", "phase1@example.test").put("type", "internal").put("path", "users"));
admin.post("/core/groups/" + admin.groupPk("phase1-child-of-identity-admins") + "/add_user/", new JSONObject().put("pk", admin.userPk("phase1.member")));
admin.post("/core/groups/", new JSONObject().put("name", "phase1-bad-attributes").put("attributes",
new JSONObject().put("qqq.io/permissions", new JSONArray().put("no.such.permission")).put("qqq.io/app-access", new JSONArray().put("not-in-scope-app"))));
assertThat(FlowLogin.login(stack.baseUrl(), "akadmin", AuthentikTestStack.BOOTSTRAP_PASSWORD)).isEqualTo("xak-flow-redirect");
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG))
.withListCacheTtlSeconds(0)
.withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, QbitReadContractIT.RecordingActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(config.applyPrefix(table));
input.setFilter(filter);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Memberships by group, by user, by key, and a full sweep.
*******************************************************************************/
@Test
void memberships() throws QException
{
TestApi admin = stack.admin();
String groupPk = admin.groupPk("phase1-child-of-identity-admins");
int userPk = admin.userPk("phase1.member");
List<QRecord> byGroup = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
assertThat(byGroup).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
assertThat(byGroup.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
assertThat(byGroup.get(0).getValueString("email")).isEqualTo("phase1@example.test");
FixtureRecorder.record("group-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
List<QRecord> byUser = query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)));
assertThat(byUser).extracting(r -> r.getValueString("groupName")).containsExactly("phase1-child-of-identity-admins");
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))))
.extracting(r -> r.getValueString("groupName")).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
int all = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount();
assertThat(all).isGreaterThanOrEqualTo(3);
FixtureRecorder.record("groups-list-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
}
/*******************************************************************************
** Groups: inherited superuser and effective permissions (incl. parents);
** users: effective permissions through a child group.
*******************************************************************************/
@Test
void effectivePermissionsAndInheritedSuperuser() throws QException
{
QRecord child = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins"))).get(0);
assertThat(child.getValueString("permissions")).isNull();
assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess", "identity.users.read.hasAccess");
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
QRecord admins = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, AuthentikTestStack.SUPERUSER_GROUP))).get(0);
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
QRecord member = query("user", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member"))).get(0);
assertThat(member.getValueString("effectivePermissions")).contains("identity.permissions.manage.hasAccess");
}
/*******************************************************************************
** Sessions and events: audit.read only.
*******************************************************************************/
@Test
void sessionsAndEvents() throws QException
{
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> query("event", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
assertThat(sessions).isNotEmpty();
assertThat(sessions.get(0).getValueString("lastIp")).isNotBlank();
FixtureRecorder.record("sessions-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/authenticated_sessions/"));
List<QRecord> logins = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.EQUALS, "login"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")).withLimit(5));
assertThat(logins).isNotEmpty();
assertThat(logins.get(0).getValueString("action")).isEqualTo("login");
List<QRecord> modelEvents = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.IN, List.of("model_created", "model_updated")),
new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(10));
assertThat(modelEvents).isNotEmpty().allMatch(r -> "group".equals(r.getValueString("modelName")));
FixtureRecorder.record("events-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
}
/*******************************************************************************
** The drift report on the contract instance. Expected: blueprints, service
** account and break-glass OK; the bad attributes flagged; and the
** service account's lockout probes on break-glass (SuperuserFloorIT)
** flagged as an ERROR.
*******************************************************************************/
@Test
void driftReport() throws QException
{
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
List<DriftFinding> findings = new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
findings.forEach(f -> System.out.println("[drift] " + f));
assertThat(findings).anyMatch(f -> f.check().equals("version") && f.severity() == DriftFinding.Severity.OK);
assertThat(findings).anyMatch(f -> f.check().equals("break-glass") && f.severity() == DriftFinding.Severity.OK && f.subject().contains(AuthentikTestStack.BREAK_GLASS));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.OK);
assertThat(findings).anyMatch(f -> f.check().equals("identity admins") && f.subject().contains("phase1.member"));
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("no.such.permission"));
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("not-in-scope-app"));
assertThat(findings).anyMatch(f -> f.check().equals("service-account activity") && f.severity() == DriftFinding.Severity.ERROR);
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
FixtureRecorder.record("blueprints-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/managed/blueprints/"));
FixtureRecorder.record("tokens-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/tokens/"));
FixtureRecorder.record("events-by-service-account", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
}
}
@@ -0,0 +1,189 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.drift;
import java.util.List;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
import com.kingsrook.qbits.authentik.metadata.AuthentikDriftReportWidgetRenderer;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Drift report on the recorded 2026.8.3 contract-test instance, and the
** scenarios it must catch.
*******************************************************************************/
class AuthentikDriftReportTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
private FixtureRouter router;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withApplicationsInScope(List.of("kof22-website-prod")).withGroupCacheTtlSeconds(0);
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
router = new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private List<DriftFinding> run() throws QException
{
router.refreshGraph();
return (new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run());
}
/*******************************************************************************
**
*******************************************************************************/
private static DriftFinding find(List<DriftFinding> findings, String check, DriftFinding.Severity severity)
{
return (findings.stream().filter(f -> f.check().equals(check) && f.severity() == severity).findFirst().orElse(null));
}
/*******************************************************************************
** The recorded instance, as the contract test saw it.
*******************************************************************************/
@Test
void recordedInstance() throws QException
{
List<DriftFinding> findings = run();
assertThat(find(findings, "version", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "blueprints", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "service account", DriftFinding.Severity.OK)).isNotNull();
assertThat(find(findings, "break-glass", DriftFinding.Severity.OK)).isNotNull();
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().contains("no.such.permission"));
assertThat(find(findings, "service-account activity", DriftFinding.Severity.ERROR)).as("the lockout probes on break-glass").isNotNull();
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
}
/*******************************************************************************
** Unsafe states.
*******************************************************************************/
@Test
void unsafeStates() throws QException
{
JSONObject serviceAccount = router.users.stream().filter(u -> u.getString("username").equals("qqq-kof22-admin-idadmin")).findFirst().orElseThrow();
serviceAccount.put("is_superuser", true).put("type", "internal").put("is_active", false).put("attributes", new JSONObject())
.put("groups_obj", new JSONArray().put(new JSONObject().put("pk", "x").put("name", "authentik Admins")));
router.tokens.add(new JSONObject(router.tokens.get(0).toString()).put("identifier", "second"));
router.blueprints.get(0).put("status", "error");
router.users.forEach(u -> u.put("attributes", new JSONObject()));
router.groups.forEach(g -> g.put("users_obj", new JSONArray()).put("users", new JSONArray()));
router.events.clear();
List<DriftFinding> findings = run();
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.ERROR && f.message().contains("SUPERUSER"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("type is internal"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("inactive"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("qqq.io/protected"));
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("2 API tokens"));
assertThat(find(findings, "blueprints", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "break-glass", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "identity admins", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "service-account activity", DriftFinding.Severity.OK)).isNotNull();
}
/*******************************************************************************
** Missing service account, version mismatch, a check that cannot run.
*******************************************************************************/
@Test
void missingAndUnreachable() throws QException
{
router.users.removeIf(u -> u.getString("username").equals("qqq-kof22-admin-idadmin"));
config.withExpectedServerVersion("2099.1");
router.blueprints.clear();
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/managed/blueprints/")
? FixtureActionUtil.response(403, "{\"detail\":\"You do not have permission to perform this action.\"}")
: router.apply(request);
List<DriftFinding> findings = run();
assertThat(find(findings, "service account", DriftFinding.Severity.ERROR)).isNotNull();
assertThat(find(findings, "version", DriftFinding.Severity.WARNING)).isNotNull();
assertThat(findings).anyMatch(f -> f.check().equals("blueprints") && f.message().startsWith("Check could not run"));
}
/*******************************************************************************
** The widget renders the findings, and needs identity.admin.
*******************************************************************************/
@Test
void widgetRendersAndChecksPermission() throws QException
{
RenderWidgetInput input = new RenderWidgetInput();
input.setWidgetMetaData(qInstance.getWidget(config.applyPrefix("driftReport")));
TableData table = (TableData) new AuthentikDriftReportWidgetRenderer().render(input).getWidgetData();
assertThat(table.getColumns()).hasSize(4);
assertThat(table.getRows()).isNotEmpty();
assertThat(table.getRows().get(0).get("severity")).isEqualTo("ERROR");
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
}
}
@@ -0,0 +1,157 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
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.AuthentikTestSupport;
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
import com.kingsrook.qbits.authentik.client.FixtureRouter;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for AuthentikGroupPermissionMapper over the recorded group graph.
*******************************************************************************/
class AuthentikGroupPermissionMapperTest
{
private QInstance qInstance;
private AuthentikQBitConfig config;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void beforeEach() throws QException
{
FixtureActionUtil.reset();
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperators("Boot@Example.Test");
qInstance = AuthentikTestSupport.produce(config);
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
QContext.init(qInstance, new QSession());
new FixtureRouter().install();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
QContext.clear();
FixtureActionUtil.reset();
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Object> context(String email, Object groups)
{
Map<String, Object> context = new HashMap<>();
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", email).put("groups", groups));
return (context);
}
/*******************************************************************************
** Inherited identity.* through a child group; existing permissions kept.
*******************************************************************************/
@Test
void groupsBecomePermissions()
{
QSession session = new QSession();
session.setPermissions(Set.of("already.there"));
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group")));
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
}
assertThat(session.getPermissions()).contains("already.there");
}
/*******************************************************************************
** Static fallbacks, bootstrap operators (case-insensitive), single-string
** groups claim; no claims means no change.
*******************************************************************************/
@Test
void fallbacksAndBootstrap()
{
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("kof22-admins", List.of("adminEsb.hasAccess"));
QSession admin = new QSession();
mapper.customizeSession(qInstance, admin, context("someone@example.test", "kof22-admins"));
assertThat(admin.getPermissions()).contains("adminEsb.hasAccess").doesNotContain(IdentityPermission.USERS_READ.getSessionPermission());
QSession boot = new QSession();
mapper.customizeSession(qInstance, boot, context("boot@example.test", new JSONArray()));
assertThat(boot.getPermissions()).contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission());
QSession none = new QSession();
mapper.customizeSession(qInstance, none, Map.of());
mapper.customizeSession(qInstance, none, null);
assertThat(none.getPermissions()).isNullOrEmpty();
}
/*******************************************************************************
** Authentik unreachable: only the static fallbacks, never everything.
*******************************************************************************/
@Test
void authentikDownGrantsOnlyFallbacks()
{
FixtureActionUtil.groupGraphBody = "not json";
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("cms.publish"));
QSession session = new QSession();
mapper.customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins")));
assertThat(session.getPermissions()).containsExactly("cms.publish");
AuthentikGroupPermissionMapper wrongBackend = new AuthentikGroupPermissionMapper()
{
@Override
protected String getApiBackendName()
{
return ("nope");
}
};
assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "x@example.test")).isEmpty();
}
}
@@ -0,0 +1,157 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik.permissions;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.authentik.security.IdentityPermission;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Tests for GroupGraph and EffectivePermissionResolver.
*******************************************************************************/
class EffectivePermissionResolverTest
{
/*******************************************************************************
** admins (superuser) <- under-admins; identity-admins <- editors <- interns;
** publishers (string attribute); a cycle a <-> b.
*******************************************************************************/
private static GroupGraph graph()
{
return (GroupGraph.fromGroups(List.of(
group("admins", "authentik Admins", true, List.of(), null, null, List.of(1)),
group("under", "under-admins", false, List.of("admins"), List.of("cms.publish"), null, List.of(2)),
group("ida", "identity-admins", false, List.of(), List.of("identity.*"), List.of("kof22-website-prod"), List.of(3)),
group("editors", "editors", false, List.of("ida"), List.of("cms.edit", "cms.*"), null, List.of(4)),
group("interns", "interns", false, List.of("editors"), List.of("identity.users.read", "nope.unknown"), null, List.of(5)),
new JSONObject().put("pk", "pub").put("name", "publishers").put("is_superuser", false).put("parents", new JSONArray())
.put("attributes", new JSONObject().put("qqq.io/permissions", "cms.publish").put("qqq.io/app-access", "kof22-website-prod")).put("users", new JSONArray().put(6)),
group("a", "cycle-a", false, List.of("b"), List.of("cms.edit"), null, List.of()),
group("b", "cycle-b", false, List.of("a"), List.of(), null, List.of(7)))));
}
/*******************************************************************************
**
*******************************************************************************/
private static JSONObject group(String pk, String name, boolean superuser, List<String> parents, List<String> permissions, List<String> appAccess, List<Integer> users)
{
JSONObject attributes = new JSONObject();
if(permissions != null)
{
attributes.put("qqq.io/permissions", new JSONArray(permissions));
}
if(appAccess != null)
{
attributes.put("qqq.io/app-access", new JSONArray(appAccess));
}
return (new JSONObject().put("pk", pk).put("name", name).put("is_superuser", superuser).put("parents", new JSONArray(parents))
.put("attributes", attributes).put("users", new JSONArray(users)));
}
/*******************************************************************************
**
*******************************************************************************/
private static final Set<String> CATALOG = Set.of("cms.publish", "cms.edit", "cms.delete", "adminEsb.hasAccess");
/*******************************************************************************
**
*******************************************************************************/
@Test
void ancestryAndInheritedSuperuser()
{
GroupGraph graph = graph();
assertThat(graph.ancestorPks("interns")).containsExactly("editors", "ida");
assertThat(graph.isSuperuserInherited("under")).isTrue();
assertThat(graph.isSuperuserInherited("admins")).isTrue();
assertThat(graph.isSuperuserInherited("interns")).isFalse();
assertThat(graph.ancestorPks("a")).containsExactly("b");
assertThat(graph.ancestorPks("unknown")).isEmpty();
assertThat(graph.byName("editors").pk()).isEqualTo("editors");
assertThat(graph.groupsOfUser(4)).containsExactly("editors");
}
/*******************************************************************************
** Inheritance, wildcards, identity names, unknown names, the catalog.
*******************************************************************************/
@Test
void resolvesWithInheritanceAndWildcards()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
EffectivePermissionResolver.Resolution interns = resolver.resolveUser(5);
for(IdentityPermission permission : IdentityPermission.values())
{
assertThat(interns.sessionPermissions()).contains(permission.getSessionPermission());
}
assertThat(interns.sessionPermissions()).contains("cms.edit", "cms.publish", "cms.delete").doesNotContain("adminEsb.hasAccess", "nope.unknown");
assertThat(interns.unknown()).containsExactly("nope.unknown");
assertThat(interns.grantedBy().get("cms.delete")).containsExactly("editors");
assertThat(interns.grantedBy().get(IdentityPermission.USERS_READ.getSessionPermission())).containsExactlyInAnyOrder("identity-admins", "interns");
assertThat(interns.appAccess()).containsExactly("kof22-website-prod");
assertThat(EffectivePermissionResolver.holdsAll(interns, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE))).isTrue();
assertThat(EffectivePermissionResolver.holdsAll(resolver.resolveUser(6), List.of(IdentityPermission.USERS_READ))).isFalse();
}
/*******************************************************************************
** A single string attribute is a list of one; cycles terminate.
*******************************************************************************/
@Test
void stringAttributesAndCycles()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
assertThat(resolver.resolveUser(6).sessionPermissions()).containsExactly("cms.publish");
assertThat(resolver.resolveUser(6).appAccess()).containsExactly("kof22-website-prod");
assertThat(resolver.resolveUser(7).sessionPermissions()).containsExactly("cms.edit");
assertThat(resolver.resolveUser(99).sessionPermissions()).isEmpty();
}
/*******************************************************************************
** Without a catalog: names pass through, but wildcards are never granted
** literally.
*******************************************************************************/
@Test
void withoutCatalog()
{
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), null);
assertThat(resolver.expand("anything.goes")).containsExactly("anything.goes");
assertThat(resolver.expand("cms.*")).isEmpty();
assertThat(resolver.expand(" identity.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
assertThat(resolver.expand("identity.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
assertThat(resolver.expand("")).isEmpty();
assertThat(resolver.expand(null)).isEmpty();
}
}
@@ -1,8 +1,8 @@
{
"version_current": "2026.8.3",
"outdated": false,
"version_latest_valid": false,
"version_latest": "0.0.0",
"version_latest_valid": true,
"version_latest": "2026.8.3",
"outpost_outdated": false,
"build_hash": ""
}
@@ -0,0 +1,549 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 33,
"end_index": 33,
"total_pages": 1
},
"autocomplete": {},
"results": [
{
"path": "default/flow-default-authentication-flow.yaml",
"metadata": {
"name": "Default - Authentication flow",
"labels": {}
},
"last_applied_hash": "8c42e464620993e314bada38a90279ebfde283c0df1be15610a38f0dda1cc98a8cc16020857c2a58fcfe28c9b40117d29b73fdb197253c012b4382384b3f3c24",
"managed_models": [],
"last_applied": "2026-10-12T01:15:30.747553Z",
"name": "Default - Authentication flow",
"context": {},
"pk": "9cca0e34-bc1f-4e2c-b0d6-479d5e4fc27b",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/default-brand.yaml",
"metadata": {
"name": "Default - Brand",
"labels": {}
},
"last_applied_hash": "8f15f035ea71a470c13a975bf66d32b031ef4032b0d178a754f43d16a0abf79470929cbb860b0fe884f8f42914310b7beda32db4b34be55e754fa25fd5d7e4b1",
"managed_models": [],
"last_applied": "2026-10-12T01:15:40.903959Z",
"name": "Default - Brand",
"context": {},
"pk": "b432d6f0-9e72-4a1c-9d28-986b72becda7",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/events-default.yaml",
"metadata": {
"name": "Default - Events Transport & Rules",
"labels": {}
},
"last_applied_hash": "9cf24006d70a07a619a18e8b5707ec58bb306568a228ed8c1ebfe6639400d78df02b9042405e78e6202805ac22180b73e474fb72025652f91a26ab41623ced6d",
"managed_models": [],
"last_applied": "2026-10-12T01:15:31.539388Z",
"name": "Default - Events Transport & Rules",
"context": {},
"pk": "7e07d275-bde7-42ba-8810-9711d9bcdb0d",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-invalidation-flow.yaml",
"metadata": {
"name": "Default - Invalidation flow",
"labels": {}
},
"last_applied_hash": "a143bf2b9a1d20e2078389f7c37d213e51c52ea6a8df8f8ae7a7e6a1bb129ec4b862803aa44b8b1dbfe727e98e45cedac7a1a03632065040c87f045dcabab86a",
"managed_models": [],
"last_applied": "2026-10-12T01:15:32.416250Z",
"name": "Default - Invalidation flow",
"context": {},
"pk": "eb5a6337-3226-4a2d-95a0-031832a06311",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-oobe.yaml",
"metadata": {
"name": "Default - Out-of-box-experience flow",
"labels": {
"blueprints.goauthentik.io/system-oobe": "true",
"blueprints.goauthentik.io/system": "true"
}
},
"last_applied_hash": "38d5b1d1e6b06206bba89bd0859a389ce8324ac1ab095fc9b2a015be5a9e6fbb404bed013f870b94965630726bf183f9c1193ce25aface8211a14e2355a7b791",
"managed_models": [],
"last_applied": "2026-10-12T01:15:32.140739Z",
"name": "Default - Out-of-box-experience flow",
"context": {},
"pk": "6bc549b0-fa02-4c90-92a8-4496ce65ccff",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-password-change.yaml",
"metadata": {
"name": "Default - Password change flow",
"labels": {}
},
"last_applied_hash": "4de0acc5fd9e5c5de59cb4bf32db7f192e495ed285454daef8268409d6055b780043a27b895bd0d48262451653fd7299c73f0fdd51dfb4925958a449c6a9f49c",
"managed_models": [],
"last_applied": "2026-10-12T01:15:32.325034Z",
"name": "Default - Password change flow",
"context": {},
"pk": "ae3a5a27-10d1-4a55-af06-e3cc75e9492f",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-provider-authorization-explicit-consent.yaml",
"metadata": {
"name": "Default - Provider authorization flow (explicit consent)",
"labels": {}
},
"last_applied_hash": "598eee861d8814c0dc200542f59e5d8085c140f7131e87a9fc6baf688a3e9e7b0a32e865c439084f76b11992ce056be574e5aba2664375cee287206d66d345c9",
"managed_models": [],
"last_applied": "2026-10-12T01:15:30.362956Z",
"name": "Default - Provider authorization flow (explicit consent)",
"context": {},
"pk": "4428307d-f07c-4d18-84f0-b993bdff5219",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-provider-authorization-implicit-consent.yaml",
"metadata": {
"name": "Default - Provider authorization flow (implicit consent)",
"labels": {}
},
"last_applied_hash": "2321088d922a57fdb741f6857eb48d3090210b552a6ecadf9b08de4832f574c5231f37943da3dfda3a4fa07c900d236ace3d237710e2e986a207c758859c1ecb",
"managed_models": [],
"last_applied": "2026-10-12T01:15:32.232059Z",
"name": "Default - Provider authorization flow (implicit consent)",
"context": {},
"pk": "dfff0561-79fd-47c1-8b0c-aa62578155c5",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-provider-invalidation.yaml",
"metadata": {
"name": "Default - Provider invalidation flow",
"labels": {}
},
"last_applied_hash": "92310bf9e2aceeec2f8dc4762ce4921588cadd9f5990a23abb796bb84b61a619fc9ddf8e6a9b580a208abc8cabb15cff244238581c089d98a48c15ac5eb078ce",
"managed_models": [],
"last_applied": "2026-10-12T01:15:27.202867Z",
"name": "Default - Provider invalidation flow",
"context": {},
"pk": "772a73b6-f5bb-48f9-828b-74693208d31e",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/rbac-role-agents.yaml",
"metadata": {
"name": "Default - RBAC - Agent Users",
"labels": {}
},
"last_applied_hash": "d8340ba349322534e4013ac86f7a662ad4bb82430c13eb9d2fbd1b15a5e2bb306956497c13ec293e117ef8c88625b7325b22e6135f11474a9c49b2dc67bd1659",
"managed_models": [],
"last_applied": "2026-10-12T01:15:26.831237Z",
"name": "Default - RBAC - Agent Users",
"context": {},
"pk": "1c865e1a-22cb-43f1-bb80-aeda3d135aec",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/rbac-role-read-only.yaml",
"metadata": {
"name": "Default - RBAC - Read-only",
"labels": {}
},
"last_applied_hash": "2e44f3070adfcfc63abb1429386fd663ee179b5c9db2cfce36e515f8216543cf59d48b9a331db5955c9732024e75068433b3f2b5579b441839522b2c40a8b637",
"managed_models": [],
"last_applied": "2026-10-12T01:15:31.596000Z",
"name": "Default - RBAC - Read-only",
"context": {},
"pk": "26b9a15d-f2fc-4d05-9d63-177922a123da",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-request-flow.yaml",
"metadata": {
"name": "Default - Request flow",
"labels": {}
},
"last_applied_hash": "53365439bd7d2ada828846653d4a53556fd1a7ffd7564de5badf9beb1436d3f95bf45bbf02bf6829849f2b861a034f6aac2f53d478201bbfe7aa73cf94744407",
"managed_models": [],
"last_applied": "2026-10-12T01:15:31.680198Z",
"name": "Default - Request flow",
"context": {},
"pk": "7eccd6fb-7247-4aa6-94e1-7d290e3dacad",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-source-authentication.yaml",
"metadata": {
"name": "Default - Source authentication flow",
"labels": {}
},
"last_applied_hash": "d86613f21394af35d554692e9868e7e714bb7cbd99a362ef3b64476edc1ffcb832443610f193d3d116685a360a1734482a7152b6b6bc8913f45be9aac3934233",
"managed_models": [],
"last_applied": "2026-10-12T01:15:29.666916Z",
"name": "Default - Source authentication flow",
"context": {},
"pk": "3e07d4bc-6fb4-4079-a22f-07e5afe43feb",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-source-enrollment.yaml",
"metadata": {
"name": "Default - Source enrollment flow",
"labels": {}
},
"last_applied_hash": "a3995e67834828ddeabf14d1220547df84d870f72d743bed3cd3f27c57fefaf3c72d0e1cec85f65d48d9e5c8c7c539e3b636c2d96be94ffa3a979fb78e3e36fd",
"managed_models": [],
"last_applied": "2026-10-12T01:15:23.486786Z",
"name": "Default - Source enrollment flow",
"context": {},
"pk": "9aa59b95-4e2c-48bf-87dc-96061d64509e",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-source-pre-authentication.yaml",
"metadata": {
"name": "Default - Source pre-authentication flow",
"labels": {}
},
"last_applied_hash": "51b6b65fb60f2f925d80b38cf173e7aa1bc00a074990e8d0b0dfaae088007ceb9051d0722f41500da309af5d0c78417dfa08a40c706203c3d6ee1eb613ad564c",
"managed_models": [],
"last_applied": "2026-10-12T01:15:29.374131Z",
"name": "Default - Source pre-authentication flow",
"context": {},
"pk": "973eac96-d318-4a56-a1ab-532920e18388",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-authenticator-static-setup.yaml",
"metadata": {
"name": "Default - Static MFA setup flow",
"labels": {}
},
"last_applied_hash": "2734d44185d0f37fe3af72cc28a9cc0bd06f8b160f021412571933f3062d0b47f056e832307c3de2367651eea43793e9a105d873917370717935bc66d765ea9c",
"managed_models": [],
"last_applied": "2026-10-12T01:15:42.374678Z",
"name": "Default - Static MFA setup flow",
"context": {},
"pk": "a297b4a2-52fc-4f66-a952-5e2e9d51884b",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-authenticator-totp-setup.yaml",
"metadata": {
"name": "Default - TOTP MFA setup flow",
"labels": {}
},
"last_applied_hash": "88836c8721b8b163584bd208ceb0a29ec5a44f56e749bdd44dabf541c331da99c57dfb128e06d8f073c4ddef0e1565eefa62696aae14f01ab4ff688eda277603",
"managed_models": [],
"last_applied": "2026-10-12T01:15:22.773974Z",
"name": "Default - TOTP MFA setup flow",
"context": {},
"pk": "df7af2c7-0666-4e38-854a-4d291e27bba5",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-user-settings-flow.yaml",
"metadata": {
"name": "Default - User settings flow",
"labels": {}
},
"last_applied_hash": "fde7a4686603bbca15824b1b412e7cd6b93cb051c0286d95f31708f66cdd1150e539c8be3d18a6c2ec0871bd77d4c8e4de5654844705e821ccfaee1434c25ac9",
"managed_models": [],
"last_applied": "2026-10-12T01:15:42.002441Z",
"name": "Default - User settings flow",
"context": {},
"pk": "e260c804-6da9-4f93-b9b6-e10013594366",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "default/flow-default-authenticator-webauthn-setup.yaml",
"metadata": {
"name": "Default - WebAuthn MFA setup flow",
"labels": {}
},
"last_applied_hash": "7ec577d44f40616ac216be5039b6e0133af162c2dd9925adf51d49d15841be082ab9c98debc51a25ac1a12566a728f07845e16203446e8a724e457b5255c65c8",
"managed_models": [],
"last_applied": "2026-10-12T01:15:30.186106Z",
"name": "Default - WebAuthn MFA setup flow",
"context": {},
"pk": "139ef4ed-28c1-447c-b859-787cd387d359",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "migrations/migrate-prompt-name.yaml",
"metadata": {
"name": "Migration - Remove old prompt fields",
"labels": {"blueprints.goauthentik.io/description": "Migrate to 2023.2, remove unused prompt fields"}
},
"last_applied_hash": "75617432af2bb8d9cbf74db0ba9d6ec1b123811fdb40975685386c85d8fa4046cc4749a8e6f1f3e0c1068e45bf2af73914c6ede5b3d31fa2ac3195a53b94cf43",
"managed_models": [],
"last_applied": "2026-10-12T01:15:42.232707Z",
"name": "Migration - Remove old prompt fields",
"context": {},
"pk": "05a1d36f-d5dd-496a-8336-b8b0452d13ad",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-google-workspace.yaml",
"metadata": {
"name": "System - Google Workspace Provider - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "43e8e572057af9709f3c30c076f20987c4a8ccab629cb0a15417979b5aa865d3993e5a03e485e7f7f3232a521844cf49285304dfc57d8355affdc4477b3d5933",
"managed_models": [],
"last_applied": "2026-10-12T01:15:41.548061Z",
"name": "System - Google Workspace Provider - Mappings",
"context": {},
"pk": "4206aa46-241a-4603-86d5-3c30940be755",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/sources-kerberos.yaml",
"metadata": {
"name": "System - Kerberos Source - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "49db6f6c3ee0fba992b9ad6756b499393f4648db17592c988462fa6c8c312f4c6b2adcf4e3e1fd8653fe05ebc20c7cffa258f3959f872b9590369b74e860dca8",
"managed_models": [],
"last_applied": "2026-10-12T01:15:23.255310Z",
"name": "System - Kerberos Source - Mappings",
"context": {},
"pk": "c1ed6282-df71-44cb-83d4-ab8b1cf11948",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/sources-ldap.yaml",
"metadata": {
"name": "System - LDAP Source - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "36a01c42520e0a7929dc62005dfd701099cbbf74527718c97ea2c800fd26c1c816474cc2b402729584914ec77bdc35e26bb62d74a9e521207eebd28e3524e8e4",
"managed_models": [],
"last_applied": "2026-10-12T01:15:41.721906Z",
"name": "System - LDAP Source - Mappings",
"context": {},
"pk": "f5dc928e-aa22-4d32-a11e-0cfde1a9dc18",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-microsoft-entra.yaml",
"metadata": {
"name": "System - Microsoft Entra Provider - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "ad85e885bf0bbb0df2398b2a252f1f32f222140fbbd8df8abac83a8debedba7c57955d388330e1e22420679fe940167c2ed5b4c5a8c8cb6c742b0282b3b56732",
"managed_models": [],
"last_applied": "2026-10-12T01:15:26.771728Z",
"name": "System - Microsoft Entra Provider - Mappings",
"context": {},
"pk": "7d903a64-096a-4961-8df3-5d1bdb414ef5",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-oauth2.yaml",
"metadata": {
"name": "System - OAuth2 Provider - Scopes",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "da8a4bde589c67be18de2df424e3cd175ef2babff6e14f1649003c83fd096e1bab7f39cbf6294b49d59acca24c7f64e35d8b76fcebebb2e82c6590903db1e88d",
"managed_models": [],
"last_applied": "2026-10-12T01:15:41.081341Z",
"name": "System - OAuth2 Provider - Scopes",
"context": {},
"pk": "306554ca-93d9-4b62-9964-7956c8dd59b5",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/object-attributes-user.yaml",
"metadata": {
"name": "System - Object Attributes - User",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "dea2d6bb95198ebdb6fdda23cd5d8339105087841e9007919c92b446b98a70454d9512b882d8c884bdaf80aa926b7e91013da3239424a715d5c306ad4290d6eb",
"managed_models": [],
"last_applied": "2026-10-12T01:15:29.802102Z",
"name": "System - Object Attributes - User",
"context": {},
"pk": "059b544b-1ce7-48fa-9d52-d7728fe0218d",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-proxy.yaml",
"metadata": {
"name": "System - Proxy Provider - Scopes",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "c92fa491977a1a528005dd11953a6c524ae91cbb8f390938a8083211df02428168c883e75ce3b4a76484cad89310633ef34f2a8a6a272b27e1a53914ea53a8d5",
"managed_models": [],
"last_applied": "2026-10-12T01:15:41.151981Z",
"name": "System - Proxy Provider - Scopes",
"context": {},
"pk": "7327ddb8-a884-47dc-ba99-48b4623c1883",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-rac.yaml",
"metadata": {
"name": "System - RAC Provider - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "6332bda15401895bd478f07d4e6e6b7db90f335c61ba7d924ed8ab02bddb4bdf5f40c888b1acf24b5c51133705d825b323030461db3c46dfc4fb91d4609e5e76",
"managed_models": [],
"last_applied": "2026-10-12T01:15:26.989465Z",
"name": "System - RAC Provider - Mappings",
"context": {},
"pk": "25ece34f-ae5a-4ed5-b94e-ade794143dae",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-saml.yaml",
"metadata": {
"name": "System - SAML Provider - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "4f77ca1c7729136332a04e89a12c713a72bdf08a766132c3092586cbf8580c51aafc6f3c1f6220dd2415b6c66646632980f2f3a424cb609d62df8680e3ea7b3e",
"managed_models": [],
"last_applied": "2026-10-12T01:15:30.003502Z",
"name": "System - SAML Provider - Mappings",
"context": {},
"pk": "532b9d75-09a3-4fa1-8dfc-6f1ce6daf138",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/providers-scim.yaml",
"metadata": {
"name": "System - SCIM Provider - Mappings",
"labels": {"blueprints.goauthentik.io/system": "true"}
},
"last_applied_hash": "4dfec958adf1eec36e394e0ca48b53448eca34e24e8082c745da5cd61a0fd8b0034ca340a29f4788046f2310773e7b1e79abd91650dd47cc2406f2aa510e9b9a",
"managed_models": [],
"last_applied": "2026-10-12T01:15:21.392651Z",
"name": "System - SCIM Provider - Mappings",
"context": {},
"pk": "aca7b811-f3ed-4360-b52e-68e6ff2d104b",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "system/bootstrap.yaml",
"metadata": {
"name": "authentik Bootstrap",
"labels": {
"blueprints.goauthentik.io/system-bootstrap": "true",
"blueprints.goauthentik.io/system": "true",
"blueprints.goauthentik.io/description": "This blueprint configures the default admin user and group, and configures them for the [Automated install](https://docs.goauthentik.io/docs/install-config/automated-install?utm_source=bootstrap_blueprint).\n"
}
},
"last_applied_hash": "a654534f252c03881ba290d278bfd57014c433799b02c6b521e054a1f62751aba67114cd6c16b89b031f98706e84279e4f5ccae4da6df8f21c5556096945e685",
"managed_models": [],
"last_applied": "2026-10-12T01:15:42.104201Z",
"name": "authentik Bootstrap",
"context": {},
"pk": "0722bfd2-b49e-4cbb-a4f6-80b6f1c83f10",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "custom/kof22-identity-bootstrap.yaml",
"metadata": {
"name": "kof22-identity-bootstrap",
"labels": {"blueprints.goauthentik.io/instantiate": "true"}
},
"last_applied_hash": "4958e74112d408a5c593c6c6164af852993180c859fbe3a494b77fd8b19dae69132f2fb3cf19dc71787f114e60b8515f26215ae2d8efb87a650823c038ba8acb",
"managed_models": [],
"last_applied": "2026-10-12T01:15:26.702006Z",
"name": "kof22-identity-bootstrap",
"context": {},
"pk": "d68e6be0-4d09-4c0d-9c76-4199b0cc44f1",
"enabled": true,
"content": "",
"status": "successful"
},
{
"path": "custom/kof22-oauth2-apps.yaml",
"metadata": {
"name": "kof22-oauth2-apps",
"labels": {"blueprints.goauthentik.io/instantiate": "true"}
},
"last_applied_hash": "7113b8b72d619886c4a8c75a0ab83fe83b4949f77e8d0ceb3d521570064c1fac52b21fd5fa375e1c916648a2af147d74b3beac1ece199b5fb14d697745971119",
"managed_models": [],
"last_applied": "2026-10-12T01:15:28.407023Z",
"name": "kof22-oauth2-apps",
"context": {},
"pk": "d9230137-f9fd-4a78-9132-fd20265670b2",
"enabled": true,
"content": "",
"status": "successful"
}
]
}
@@ -0,0 +1,486 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 9,
"end_index": 9,
"total_pages": 1
},
"autocomplete": {
"models": {
"authentik_events.event_brand": {
"app": {
"nullable": false,
"options": null,
"type": "str"
},
"model_name": {
"nullable": false,
"options": null,
"type": "str"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"pk": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event_user": {
"pk": {
"nullable": false,
"options": null,
"type": "int"
},
"email": {
"nullable": false,
"options": null,
"type": "str"
},
"username": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event_context_http_request": {
"args": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context_http_request_args"
},
"path": {
"nullable": false,
"options": null,
"type": "str"
},
"method": {
"nullable": false,
"options": null,
"type": "str"
},
"request_id": {
"nullable": false,
"options": null,
"type": "str"
},
"user_agent": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event": {
"app": {
"nullable": false,
"options": [
"authentik.core.api.tokens",
"authentik.events.middleware",
"authentik.events.models",
"authentik.events.signals"
],
"type": "str"
},
"created": {
"nullable": false,
"options": [],
"type": "datetime"
},
"context": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context"
},
"action": {
"nullable": false,
"options": [
"login",
"login_failed",
"logout",
"user_write",
"user_offboarded",
"suspicious_request",
"password_set",
"secret_view",
"secret_rotate",
"invitation_used",
"authorize_application",
"source_linked",
"impersonation_started",
"impersonation_ended",
"flow_execution",
"policy_execution",
"policy_exception",
"property_mapping_exception",
"system_task_execution",
"system_task_exception",
"system_exception",
"configuration_error",
"configuration_warning",
"model_created",
"model_updated",
"model_deleted",
"email_sent",
"update_available",
"export_ready",
"review_initiated",
"review_overdue",
"review_attested",
"review_completed",
"access_request_created",
"access_request_approved",
"access_request_denied",
"access_request_revoked",
"custom_"
],
"type": "str"
},
"client_ip": {
"nullable": false,
"options": null,
"type": "str"
},
"event_uuid": {
"nullable": false,
"options": null,
"type": "str"
},
"user": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_user"
},
"brand": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_brand"
}
},
"authentik_events.event_context": {"http_request": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context_http_request"
}}
},
"current_model": "authentik_events.event"
},
"results": [
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:34.820256Z",
"created": "2026-10-12T01:15:34.822932Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "floor-plain",
"pk": "65283c7b44e844749cf3e61b54fa725c"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/remove_user/",
"method": "POST",
"request_id": "5aaa9626c90a4e0ead7d0d5ea3515933",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "0a8aa038-5571-4a96-bc76-4a4045fb50bc",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:34.693177Z",
"created": "2026-10-12T01:15:34.695787Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "floor-plain",
"pk": "65283c7b44e844749cf3e61b54fa725c"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/add_user/",
"method": "POST",
"request_id": "7e3392719cb7460fb2fa98391d9ebeec",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "59a6957e-d6d3-4a0e-b561-0f5a824c4a8f",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.core.api.tokens",
"expires": "2027-10-12T01:15:33.977233Z",
"created": "2026-10-12T01:15:33.980004Z",
"context": {
"http_request": {
"args": {},
"path": "/api/v3/core/tokens/floor-token/view_key/",
"method": "GET",
"request_id": "530cfcb3ea0a4ce2acf64c467db7436a",
"user_agent": "Java-http-client/21.0.12.1"
},
"secret": {
"app": "authentik_core",
"model_name": "token",
"name": "floor-token (expires=2026-10-13 01:15:33.719575+00:00)",
"pk": "aca8159c23c7473f9f3e7b45fc044b06"
}
},
"action": "secret_view",
"client_ip": "::ffff:172.26.0.1",
"pk": "7d0cca6d-3ad1-4f0c-aa06-9fe7112c7fe4",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:33.771579Z",
"created": "2026-10-12T01:15:33.772503Z",
"context": {
"model": {
"app": "authentik_rbac",
"model_name": "role",
"name": "ak-managed-role--user-7",
"pk": "71d5471a605249b9aa3559bfa7fd4f6d"
},
"http_request": {
"args": {},
"path": "/api/v3/core/tokens/",
"method": "POST",
"request_id": "f39d83318d7e4a06817abaf3d6357988",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_created",
"client_ip": "::ffff:172.26.0.1",
"pk": "1b01b1f9-5228-45e2-91b1-e270279ee397",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:33.729317Z",
"created": "2026-10-12T01:15:33.732414Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "token",
"name": "floor-token (expires=2026-10-13 01:15:33.719575+00:00)",
"pk": "aca8159c23c7473f9f3e7b45fc044b06"
},
"http_request": {
"args": {},
"path": "/api/v3/core/tokens/",
"method": "POST",
"request_id": "f39d83318d7e4a06817abaf3d6357988",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_created",
"client_ip": "::ffff:172.26.0.1",
"pk": "d30f3cb1-b35b-4869-babd-b3cf44697d4f",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.746918Z",
"created": "2026-10-12T01:15:32.749840Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "authentik Admins renamed",
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
"method": "PATCH",
"request_id": "3d62eb9053564fa1b247206000ccff05",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "d7e2aa37-b6bf-40e8-961f-e3fb0d38f96c",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.504575Z",
"created": "2026-10-12T01:15:32.507024Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "user",
"name": "Break-glass admin",
"pk": 8
},
"http_request": {
"args": {},
"path": "/api/v3/core/users/8/",
"method": "PATCH",
"request_id": "ba40dbf9447543848251e14466a48732",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "a1fb6a93-49cb-4c16-bf24-4b6f192b1ce0",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.263662Z",
"created": "2026-10-12T01:15:32.266453Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "user",
"name": "Break-glass admin",
"pk": 8
},
"http_request": {
"args": {},
"path": "/api/v3/core/users/8/",
"method": "PATCH",
"request_id": "7471afd5942b40edad59ce82ed77157c",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "180a4778-1744-44f3-8f98-835962a0ff0f",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:31.970674Z",
"created": "2026-10-12T01:15:31.974242Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "authentik Admins",
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/remove_user/",
"method": "POST",
"request_id": "d473c1ed6fbf4b9cb963825cdde998af",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "741fe6cd-5972-4182-99c5-666c9c217a14",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
}
]
}
@@ -0,0 +1,520 @@
{
"pagination": {
"next": 2,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 14,
"end_index": 10,
"total_pages": 2
},
"autocomplete": {
"models": {
"authentik_events.event_brand": {
"app": {
"nullable": false,
"options": null,
"type": "str"
},
"model_name": {
"nullable": false,
"options": null,
"type": "str"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"pk": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event_user": {
"pk": {
"nullable": false,
"options": null,
"type": "int"
},
"email": {
"nullable": false,
"options": null,
"type": "str"
},
"username": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event_context_http_request": {
"args": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context_http_request_args"
},
"path": {
"nullable": false,
"options": null,
"type": "str"
},
"method": {
"nullable": false,
"options": null,
"type": "str"
},
"request_id": {
"nullable": false,
"options": null,
"type": "str"
},
"user_agent": {
"nullable": false,
"options": null,
"type": "str"
}
},
"authentik_events.event": {
"app": {
"nullable": false,
"options": [
"authentik.core.api.tokens",
"authentik.events.middleware",
"authentik.events.models",
"authentik.events.signals"
],
"type": "str"
},
"created": {
"nullable": false,
"options": [],
"type": "datetime"
},
"context": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context"
},
"action": {
"nullable": false,
"options": [
"login",
"login_failed",
"logout",
"user_write",
"user_offboarded",
"suspicious_request",
"password_set",
"secret_view",
"secret_rotate",
"invitation_used",
"authorize_application",
"source_linked",
"impersonation_started",
"impersonation_ended",
"flow_execution",
"policy_execution",
"policy_exception",
"property_mapping_exception",
"system_task_execution",
"system_task_exception",
"system_exception",
"configuration_error",
"configuration_warning",
"model_created",
"model_updated",
"model_deleted",
"email_sent",
"update_available",
"export_ready",
"review_initiated",
"review_overdue",
"review_attested",
"review_completed",
"access_request_created",
"access_request_approved",
"access_request_denied",
"access_request_revoked",
"custom_"
],
"type": "str"
},
"client_ip": {
"nullable": false,
"options": null,
"type": "str"
},
"event_uuid": {
"nullable": false,
"options": null,
"type": "str"
},
"user": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_user"
},
"brand": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_brand"
}
},
"authentik_events.event_context": {"http_request": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_events.event_context_http_request"
}}
},
"current_model": "authentik_events.event"
},
"results": [
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:41.304242Z",
"created": "2026-10-12T01:15:41.305079Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "phase1-bad-attributes",
"pk": "9f6f47fc18aa4a2da8a1874ebc93939a"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/",
"method": "POST",
"request_id": "a5c1ff0a98474e90a8656fffe77aaf44",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_created",
"client_ip": "::ffff:172.26.0.1",
"pk": "1e29dbee-b80b-4a4a-ad40-15a4e44c15a4",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:41.196525Z",
"created": "2026-10-12T01:15:41.197572Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "phase1-child-of-identity-admins",
"pk": "a5c199450e4641bb85b68853eb3f375d"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/a5c19945-0e46-41bb-85b6-8853eb3f375d/add_user/",
"method": "POST",
"request_id": "cb9cc4cf6a75443281704de20b1303a7",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "fcf4c969-8ad9-485c-92e0-1c9b5d1b5b34",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:40.707696Z",
"created": "2026-10-12T01:15:40.708545Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "phase1-child-of-identity-admins",
"pk": "a5c199450e4641bb85b68853eb3f375d"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/",
"method": "POST",
"request_id": "a761aec34f40435d83613d6745641069",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "e411eff2-6675-4f88-89c7-91821e7f666d",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:40.670838Z",
"created": "2026-10-12T01:15:40.671750Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "phase1-child-of-identity-admins",
"pk": "a5c199450e4641bb85b68853eb3f375d"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/",
"method": "POST",
"request_id": "a761aec34f40435d83613d6745641069",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_created",
"client_ip": "::ffff:172.26.0.1",
"pk": "cd6f91a5-dd47-4caf-84af-dd121bc9eb16",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:36.198409Z",
"created": "2026-10-12T01:15:36.199330Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "kof-website-publishers",
"pk": "014cb7a18c864990a92cedf6985939a7"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/014cb7a1-8c86-4990-a92c-edf6985939a7/add_user/",
"method": "POST",
"request_id": "0b6d047cc050411a943769a8407f4800",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "f29fd3cb-2dc4-45bf-99d7-8d261459f930",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:34.820256Z",
"created": "2026-10-12T01:15:34.822932Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "floor-plain",
"pk": "65283c7b44e844749cf3e61b54fa725c"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/remove_user/",
"method": "POST",
"request_id": "5aaa9626c90a4e0ead7d0d5ea3515933",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "0a8aa038-5571-4a96-bc76-4a4045fb50bc",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:34.693177Z",
"created": "2026-10-12T01:15:34.695787Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "floor-plain",
"pk": "65283c7b44e844749cf3e61b54fa725c"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/add_user/",
"method": "POST",
"request_id": "7e3392719cb7460fb2fa98391d9ebeec",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "59a6957e-d6d3-4a0e-b561-0f5a824c4a8f",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.863998Z",
"created": "2026-10-12T01:15:32.864931Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "authentik Admins",
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
"method": "PATCH",
"request_id": "1bb86894bd8d4941b35b2b982b0b0e67",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "e402f70e-3cab-4532-aed4-1f29ac66fbc8",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.746918Z",
"created": "2026-10-12T01:15:32.749840Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "authentik Admins renamed",
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
"method": "PATCH",
"request_id": "3d62eb9053564fa1b247206000ccff05",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "d7e2aa37-b6bf-40e8-961f-e3fb0d38f96c",
"user": {
"pk": 7,
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
},
{
"app": "authentik.events.middleware",
"expires": "2027-10-12T01:15:32.126855Z",
"created": "2026-10-12T01:15:32.127799Z",
"context": {
"model": {
"app": "authentik_core",
"model_name": "group",
"name": "authentik Admins",
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
},
"http_request": {
"args": {},
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/add_user/",
"method": "POST",
"request_id": "95bf776e7597468f8004474d067569cd",
"user_agent": "Java-http-client/21.0.12.1"
}
},
"action": "model_updated",
"client_ip": "::ffff:172.26.0.1",
"pk": "fcfb303a-ee9f-483c-bd58-c82090ab1d2f",
"user": {
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
"brand": {
"app": "authentik_brands",
"model_name": "brand",
"name": "Default brand",
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
}
}
]
}
@@ -0,0 +1,60 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 1,
"end_index": 1,
"total_pages": 1
},
"autocomplete": {
"models": {
"authentik_core.group": {
"is_superuser": {
"nullable": true,
"options": null,
"type": "bool"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"attributes": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_core.group_attributes"
}
},
"authentik_core.group_attributes": {}
},
"current_model": "authentik_core.group"
},
"results": [{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 22032,
"users_obj": [{
"uid": "20731f37d1294a501fbd54f5092ea9b81f03fda1349b2394aa893873670ec195",
"is_active": true,
"last_login": null,
"name": "Phase One",
"attributes": {},
"pk": 14,
"email": "phase1@example.test",
"username": "phase1.member"
}],
"users": [14],
"parents_obj": null,
"children": [],
"name": "phase1-child-of-identity-admins",
"attributes": {},
"pk": "a5c19945-0e46-41bb-85b6-8853eb3f375d",
"children_obj": null,
"parents": ["2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed"]
}]
}
@@ -0,0 +1,267 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 11,
"end_index": 11,
"total_pages": 1
},
"autocomplete": {
"models": {
"authentik_core.group": {
"is_superuser": {
"nullable": true,
"options": null,
"type": "bool"
},
"name": {
"nullable": false,
"options": null,
"type": "str"
},
"attributes": {
"nullable": false,
"options": null,
"type": "relation",
"relation": "authentik_core.group_attributes"
}
},
"authentik_core.group_attributes": {}
},
"current_model": "authentik_core.group"
},
"results": [
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": true,
"roles": [],
"num_pk": 26171,
"users_obj": [
{
"uid": "f36b1954a79a34292da5065aa22c92661cbfee796a6683f4550f24ce05d2ea9c",
"is_active": true,
"last_login": "2026-10-12T01:15:42.211224Z",
"name": "authentik Default Admin",
"attributes": {},
"pk": 4,
"email": "akadmin@example.test",
"username": "akadmin"
},
{
"uid": "5ec4f8d482fa84b43d02d51827dbcac6d160fcd7e058acabfde3cf935e03184a",
"is_active": true,
"last_login": null,
"name": "Break-glass admin",
"attributes": {"qqq.io/protected": true},
"pk": 8,
"email": "",
"username": "kof22-breakglass"
}
],
"users": [
4,
8
],
"parents_obj": null,
"children": ["db725d12-8ed2-46c8-8f98-3a43325302a6"],
"name": "authentik Admins",
"attributes": {},
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [{
"name": "authentik Agent-Users",
"pk": "d6e97495-fe8d-4084-9c12-ccc466afa21d"
}],
"is_superuser": false,
"roles": ["d6e97495-fe8d-4084-9c12-ccc466afa21d"],
"num_pk": 23858,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "authentik Agent-Users",
"attributes": {"notes": "A group with an auto-generated role that allows its members to create agent users.\n"},
"pk": "b37d9565-490f-47aa-9d00-b8b1470241be",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [{
"name": "authentik Read-only",
"pk": "4db176bf-888d-47f9-913d-448ded912eb8"
}],
"is_superuser": false,
"roles": ["4db176bf-888d-47f9-913d-448ded912eb8"],
"num_pk": 32585,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "authentik Read-only",
"attributes": {"notes": "A group with an auto-generated role that allows read-only permissions on all objects.\n"},
"pk": "f525c9f5-6e92-4694-af30-93efcbafd4b2",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 29169,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "floor-child-of-admins",
"attributes": {},
"pk": "db725d12-8ed2-46c8-8f98-3a43325302a6",
"children_obj": null,
"parents": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"]
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 13446,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "floor-plain",
"attributes": {},
"pk": "65283c7b-44e8-4474-9cf3-e61b54fa725c",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 62033,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": ["a5c19945-0e46-41bb-85b6-8853eb3f375d"],
"name": "identity-admins",
"attributes": {
"qqq.io/app-access": ["kof22-website-prod"],
"qqq.io/permissions": ["identity.*"]
},
"pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 20911,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "kof-website-drafters",
"attributes": {},
"pk": "9d517d04-2803-41e2-bf9f-d6714d290b90",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 17275,
"users_obj": [{
"uid": "dcc9281974526cbcdab77e51b5fb506584e8e7d954ae55bca93f5760a91cc8dc",
"is_active": true,
"last_login": null,
"name": "Read Probe 1",
"attributes": {},
"pk": 10,
"email": "read.probe1@example.test",
"username": "read.probe1"
}],
"users": [10],
"parents_obj": null,
"children": [],
"name": "kof-website-publishers",
"attributes": {},
"pk": "014cb7a1-8c86-4990-a92c-edf6985939a7",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 10530,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "kof22-admins",
"attributes": {},
"pk": "4f39d0b0-228d-418d-8bf2-9c2c3f5595fd",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 21192,
"users_obj": [],
"users": [],
"parents_obj": null,
"children": [],
"name": "phase1-bad-attributes",
"attributes": {
"qqq.io/app-access": ["not-in-scope-app"],
"qqq.io/permissions": ["no.such.permission"]
},
"pk": "9f6f47fc-18aa-4a2d-a8a1-874ebc93939a",
"children_obj": null,
"parents": []
},
{
"inherited_roles_obj": null,
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 22032,
"users_obj": [{
"uid": "20731f37d1294a501fbd54f5092ea9b81f03fda1349b2394aa893873670ec195",
"is_active": true,
"last_login": null,
"name": "Phase One",
"attributes": {},
"pk": 14,
"email": "phase1@example.test",
"username": "phase1.member"
}],
"users": [14],
"parents_obj": null,
"children": [],
"name": "phase1-child-of-identity-admins",
"attributes": {},
"pk": "a5c19945-0e46-41bb-85b6-8853eb3f375d",
"children_obj": null,
"parents": ["2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed"]
}
]
}
@@ -37,7 +37,7 @@
"roles_obj": [],
"is_superuser": false,
"roles": [],
"num_pk": 97374,
"num_pk": 62033,
"users_obj": null,
"users": [],
"parents_obj": [],
@@ -47,7 +47,7 @@
"qqq.io/app-access": ["kof22-website-prod"],
"qqq.io/permissions": ["identity.*"]
},
"pk": "4941bc80-3f27-446f-b4d3-ba023f044b65",
"pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed",
"children_obj": [],
"parents": []
}]
@@ -0,0 +1,44 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 1,
"end_index": 1,
"total_pages": 1
},
"autocomplete": {},
"results": [{
"last_ip": "::ffff:172.26.0.1",
"current": false,
"expires": "2026-10-13T01:15:42.224550Z",
"geo_ip": null,
"last_used": "2026-10-12T01:15:42.225072Z",
"last_user_agent": "Java-http-client/21.0.12.1",
"uuid": "3d4611d5-ea75-4e48-b1f2-cdd6b5145a86",
"asn": null,
"user": 4,
"user_agent": {
"string": "Java-http-client/21.0.12.1",
"os": {
"patch": null,
"patch_minor": null,
"major": null,
"minor": null,
"family": "Other"
},
"device": {
"model": null,
"family": "Other",
"brand": null
},
"user_agent": {
"patch": null,
"major": null,
"minor": null,
"family": "Other"
}
}
}]
}
@@ -0,0 +1,56 @@
{
"pagination": {
"next": 0,
"current": 1,
"previous": 0,
"start_index": 1,
"count": 1,
"end_index": 1,
"total_pages": 1
},
"autocomplete": {},
"results": [{
"identifier": "qqq-contract-test",
"user_obj": {
"roles_obj": [
{
"name": "qqq-kof22-admin-idadmin",
"pk": "f8a6aea0-d491-4fd7-87cd-9764175bb5f2"
},
{
"name": "ak-managed-role--user-7",
"pk": "71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
}
],
"last_updated": "2026-10-12T01:15:26.584286Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T01:15:26.571298Z",
"roles": [
"f8a6aea0-d491-4fd7-87cd-9764175bb5f2",
"71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
],
"groups": [],
"avatar": "",
"type": "service_account",
"groups_obj": [],
"uuid": "7035a7ac-e5f7-4b49-b35b-043d05471854",
"uid": "917ea81f617d6f499ff98a9b32cc33d8023569e39f5ec156c1574d3a4c7b5c52",
"path": "service-accounts/qqq",
"name": "QQQ kof22 Admin identity service",
"attributes": {"qqq.io/protected": true},
"pk": 7,
"date_joined": "2026-10-12T01:15:26.570216Z",
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
"expires": "2026-10-13T01:15:30.631896Z",
"expiring": false,
"managed": null,
"description": "",
"pk": "fe0ddb9c-aca3-456b-acc8-49541e746f60",
"intent": "api",
"user": 7
}]
}
@@ -1,22 +1,22 @@
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.589851Z",
"last_updated": "2026-10-12T01:15:35.431590Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.548432Z",
"password_change_date": "2026-10-12T01:15:35.388242Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
"uuid": "9a78e0c0-5514-44c7-9c95-bf8f1d0f6722",
"uid": "90f11d41d0bf311145bda65760bd48cfa71a32a90637dc44900157f75eb27d67",
"path": "users",
"name": "Read Probe 2",
"attributes": {},
"pk": 17,
"date_joined": "2026-10-12T00:42:44.548001Z",
"pk": 11,
"date_joined": "2026-10-12T01:15:35.387796Z",
"email": "read.probe2@example.test",
"username": "read.probe2"
}
@@ -4,8 +4,8 @@
"current": 1,
"previous": 0,
"start_index": 1,
"count": 12,
"end_index": 12,
"count": 8,
"end_index": 8,
"total_pages": 1
},
"autocomplete": {
@@ -60,321 +60,209 @@
"results": [
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:38.179289Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:38.131278Z",
"roles": [],
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "kof-website-publishers",
"attributes": {},
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
"num_pk": 26548
}],
"uuid": "6656101d-7d44-4b7f-aeb5-257b13a4447f",
"uid": "41758f45cd5b97f58994f51764097ce15a3b6daaad23a2aaadc3a47325370edb",
"path": "users",
"name": "Access Publisher",
"attributes": {},
"pk": 14,
"date_joined": "2026-10-12T00:42:38.130713Z",
"email": "",
"username": "access.publisher"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:41:39.098819Z",
"last_updated": "2026-10-12T01:15:15.501529Z",
"is_active": true,
"is_superuser": true,
"last_login": null,
"password_change_date": "2026-10-12T00:41:38.964003Z",
"password_change_date": "2026-10-12T01:15:15.391962Z",
"roles": [],
"groups": [
"99da4fa1-41b7-41ed-9a09-536682c4589b",
"6b62508d-14e8-4cf6-9783-682d7c94af12"
],
"avatar": "",
"type": "internal",
"groups_obj": [
{
"is_superuser": true,
"name": "authentik Admins",
"attributes": {},
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
"num_pk": 20450
},
{
"is_superuser": false,
"name": "lint-demo-group",
"attributes": {"note": "declared in git"},
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
"num_pk": 14273
}
],
"uuid": "96235ac9-286c-4a6a-ae21-4df0dab33315",
"uid": "dee4b27045f6d9ad52b880c654fbdafc4c3ce86c45d3f045e5a638ea3c4b0065",
"path": "users",
"name": "authentik Default Admin",
"attributes": {},
"pk": 4,
"date_joined": "2026-10-12T00:41:38.934836Z",
"email": "akadmin@example.test",
"username": "akadmin"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:40.507175Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:40.455393Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "e933b7a1-db0d-4d7f-af92-ebca960e04ce",
"uid": "5f82699a0cd33c6c02a73fcd8cc3a995302a22e28881733fa3dca7ef2dfff0ac",
"path": "users",
"name": "Floor Probe",
"attributes": {},
"pk": 15,
"date_joined": "2026-10-12T00:42:40.454755Z",
"email": "",
"username": "floor.probe"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:41.985973Z",
"is_active": true,
"is_superuser": true,
"last_login": null,
"password_change_date": "2026-10-12T00:41:47.932445Z",
"roles": [],
"groups": ["99da4fa1-41b7-41ed-9a09-536682c4589b"],
"groups": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": true,
"name": "authentik Admins",
"attributes": {},
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
"num_pk": 20450
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
"num_pk": 26171
}],
"uuid": "f72814ed-7574-4d20-bbcb-56545e39adab",
"uid": "67d171fe0aa7735f081344377ef53325b135026db1544efcb8e1a3c7d236cdf2",
"path": "users/breakglass",
"name": "Break-glass admin",
"attributes": {"qqq.io/protected": true},
"pk": 8,
"date_joined": "2026-10-12T00:41:47.930197Z",
"email": "",
"username": "kof22-breakglass"
"uuid": "775c03ef-72db-41d5-a71f-3142948a3182",
"uid": "f36b1954a79a34292da5065aa22c92661cbfee796a6683f4550f24ce05d2ea9c",
"path": "users",
"name": "authentik Default Admin",
"attributes": {},
"pk": 4,
"date_joined": "2026-10-12T01:15:15.366364Z",
"email": "akadmin@example.test",
"username": "akadmin"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:19.183065Z",
"last_updated": "2026-10-12T01:15:31.201219Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:19.139382Z",
"password_change_date": "2026-10-12T01:15:31.158880Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "1cca8899-bf67-4acb-8231-b82f48b61549",
"uid": "84030aa88ef5db17880ead3e60f4830246bdf0a7e40c71599a047ddf3cbac5ea",
"uuid": "50debb28-392e-41dc-a838-3b9f266e854c",
"uid": "b85d8f4b476635ed7ce070255951399c81c8a3385f7728ffd63e5c371db88864",
"path": "users",
"name": "Added Via API",
"name": "Floor Probe",
"attributes": {},
"pk": 12,
"date_joined": "2026-10-12T00:42:19.138961Z",
"pk": 9,
"date_joined": "2026-10-12T01:15:31.158474Z",
"email": "",
"username": "lint.added"
"username": "floor.probe"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:20.680396Z",
"last_updated": "2026-10-12T01:15:32.607834Z",
"is_active": true,
"is_superuser": false,
"is_superuser": true,
"last_login": null,
"password_change_date": "2026-10-12T00:42:18.514303Z",
"password_change_date": "2026-10-12T01:15:26.678105Z",
"roles": [],
"groups": ["6b62508d-14e8-4cf6-9783-682d7c94af12"],
"groups": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "lint-demo-group",
"attributes": {"note": "declared in git"},
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
"num_pk": 14273
"is_superuser": true,
"name": "authentik Admins",
"attributes": {},
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
"num_pk": 26171
}],
"uuid": "db22814d-9610-429b-ab01-9ca7fdd6a3dd",
"uid": "bb030a055fdd722735c72012ae24e9142e3626875be2ed6e82994079674b009f",
"path": "users",
"name": "Lint Demo User",
"attributes": {},
"pk": 11,
"date_joined": "2026-10-12T00:42:18.512951Z",
"uuid": "02a868ae-c72a-4272-b621-e0b9f455a1ae",
"uid": "5ec4f8d482fa84b43d02d51827dbcac6d160fcd7e058acabfde3cf935e03184a",
"path": "users/breakglass",
"name": "Break-glass admin",
"attributes": {"qqq.io/protected": true},
"pk": 8,
"date_joined": "2026-10-12T01:15:26.676488Z",
"email": "",
"username": "lint.demo.user"
"username": "kof22-breakglass"
},
{
"roles_obj": [
{
"name": "qqq-kof22-admin-idadmin",
"pk": "2de63138-3566-48e7-9cf2-913a70b2c06f"
"pk": "f8a6aea0-d491-4fd7-87cd-9764175bb5f2"
},
{
"name": "ak-managed-role--user-7",
"pk": "ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
"pk": "71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
}
],
"last_updated": "2026-10-12T00:41:47.841974Z",
"last_updated": "2026-10-12T01:15:26.584286Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:41:47.831570Z",
"password_change_date": "2026-10-12T01:15:26.571298Z",
"roles": [
"2de63138-3566-48e7-9cf2-913a70b2c06f",
"ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
"f8a6aea0-d491-4fd7-87cd-9764175bb5f2",
"71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
],
"groups": [],
"avatar": "",
"type": "service_account",
"groups_obj": [],
"uuid": "63ac79e2-ce5c-46d1-9209-8210ad2b4b9e",
"uid": "f15497ad81cf34b68a067048a07bf0ca199d9d1b8a4afa24c26b8d7211bac66d",
"uuid": "7035a7ac-e5f7-4b49-b35b-043d05471854",
"uid": "917ea81f617d6f499ff98a9b32cc33d8023569e39f5ec156c1574d3a4c7b5c52",
"path": "service-accounts/qqq",
"name": "QQQ kof22 Admin identity service",
"attributes": {"qqq.io/protected": true},
"pk": 7,
"date_joined": "2026-10-12T00:41:47.829884Z",
"date_joined": "2026-10-12T01:15:26.570216Z",
"email": "",
"username": "qqq-kof22-admin-idadmin"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.388384Z",
"last_updated": "2026-10-12T01:15:35.225848Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.335235Z",
"password_change_date": "2026-10-12T01:15:35.181395Z",
"roles": [],
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
"groups": ["014cb7a1-8c86-4990-a92c-edf6985939a7"],
"avatar": "",
"type": "internal",
"groups_obj": [{
"is_superuser": false,
"name": "kof-website-publishers",
"attributes": {},
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
"num_pk": 26548
"pk": "014cb7a1-8c86-4990-a92c-edf6985939a7",
"num_pk": 17275
}],
"uuid": "7f8691f0-1ec0-42da-a24f-cb71bcb64c17",
"uid": "07df95d4db5f9cd781eac774b3b8129b7bf9ee3106a4c9a2f830d5fbdb85c583",
"uuid": "806d627d-b4f9-4ccc-abc9-e900555b2294",
"uid": "dcc9281974526cbcdab77e51b5fb506584e8e7d954ae55bca93f5760a91cc8dc",
"path": "users",
"name": "Read Probe 1",
"attributes": {},
"pk": 16,
"date_joined": "2026-10-12T00:42:44.334765Z",
"pk": 10,
"date_joined": "2026-10-12T01:15:35.180911Z",
"email": "read.probe1@example.test",
"username": "read.probe1"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.589851Z",
"last_updated": "2026-10-12T01:15:35.431590Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.548432Z",
"password_change_date": "2026-10-12T01:15:35.388242Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
"uuid": "9a78e0c0-5514-44c7-9c95-bf8f1d0f6722",
"uid": "90f11d41d0bf311145bda65760bd48cfa71a32a90637dc44900157f75eb27d67",
"path": "users",
"name": "Read Probe 2",
"attributes": {},
"pk": 17,
"date_joined": "2026-10-12T00:42:44.548001Z",
"pk": 11,
"date_joined": "2026-10-12T01:15:35.387796Z",
"email": "read.probe2@example.test",
"username": "read.probe2"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:44.790626Z",
"last_updated": "2026-10-12T01:15:35.630722Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.740536Z",
"password_change_date": "2026-10-12T01:15:35.588222Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "9cb596e1-ac25-4c9c-aa49-e9a66e07a5fd",
"uid": "961c6ec5f4e73114ab10c834f18cf279fb6a61b53144302674144c73f7194932",
"uuid": "22d18ca3-f66b-4f5f-a671-2dceeba29940",
"uid": "19ec3e5551d54398faa4d0de1e894e0ad9bba962f77a65eb821e5e1ee387b094",
"path": "users",
"name": "Read Probe 3",
"attributes": {},
"pk": 18,
"date_joined": "2026-10-12T00:42:44.740046Z",
"pk": 12,
"date_joined": "2026-10-12T01:15:35.587830Z",
"email": "read.probe3@example.test",
"username": "read.probe3"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:45.576270Z",
"last_updated": "2026-10-12T01:15:36.387682Z",
"is_active": false,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:44.997114Z",
"password_change_date": "2026-10-12T01:15:35.806263Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "9c523de5-9a37-4510-85d3-4cb23a8ba6df",
"uid": "41ae72db125e854553e33f6ca481dc2dc2194fe5b0154a44a2f3cd0482e281b9",
"uuid": "3ae17938-316f-4581-b9b0-3dde5a06c5d8",
"uid": "59bb228eca9f1811bf03086191d87a018c52f9b503de895e72c41e8569554061",
"path": "users",
"name": "Read Probe 4",
"attributes": {},
"pk": 19,
"date_joined": "2026-10-12T00:42:44.996692Z",
"pk": 13,
"date_joined": "2026-10-12T01:15:35.805761Z",
"email": "read.probe4@example.test",
"username": "read.probe4"
},
{
"roles_obj": [],
"last_updated": "2026-10-12T00:42:22.763043Z",
"is_active": true,
"is_superuser": false,
"last_login": null,
"password_change_date": "2026-10-12T00:42:22.714165Z",
"roles": [],
"groups": [],
"avatar": "",
"type": "internal",
"groups_obj": [],
"uuid": "b6ec0c40-5bff-4cb3-8434-7fe74c55c879",
"uid": "e3bf082dd81daa0f56be03530c403e048dce22e7a522e02594897674fdf3b51f",
"path": "users",
"name": "Reapply Member",
"attributes": {},
"pk": 13,
"date_joined": "2026-10-12T00:42:22.713832Z",
"email": "",
"username": "reapply.member"
}
]
}