diff --git a/AGENTS.md b/AGENTS.md index daadee7..8720473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `.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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e4bb2..1fd1df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 76c279b..e5809d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.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) diff --git a/README.md b/README.md index 9feb3eb..716e127 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/PHASE0-OWNER-CHECKLIST.md b/docs/PHASE0-OWNER-CHECKLIST.md index a1b91aa..fb3c206 100644 --- a/docs/PHASE0-OWNER-CHECKLIST.md +++ b/docs/PHASE0-OWNER-CHECKLIST.md @@ -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-.dump before.yaml after.yaml`. +3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-.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` diff --git a/docs/STATUS.md b/docs/STATUS.md index cd09732..bdaf80f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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). diff --git a/docs/cluster-gitops-phase0/rehearse-phase0.sh b/docs/cluster-gitops-phase0/rehearse-phase0.sh index 4344367..0d816a4 100644 --- a/docs/cluster-gitops-phase0/rehearse-phase0.sh +++ b/docs/cluster-gitops-phase0/rehearse-phase0.sh @@ -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; } diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java index 9df6b45..8203664 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java @@ -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 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). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java index 9f72990..036a871 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikRuntime.java @@ -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 responseCache; + private final Memoization groupGraphCache; + private volatile java.util.Set permissionCatalog; private final Map 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(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500); + this.groupGraphCache = new Memoization(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 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 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); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java index b189d12..8d1dfe0 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikActionUtil.java @@ -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 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 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 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 params) throws QUserFacingException + { + String pathPrefix = runtime().getConfig().getUserScopePathStartsWith(); + List 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 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 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 cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult()); + if(cached.isPresent()) + { + return (cached.get()); + } + } + + List 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 memberships(AuthentikTableSpec spec, QQueryFilter filter) throws QException + { + AuthentikFilterMapper.MappedFilter mapped = runtime().getFilterMapper().map(spec, filter, runtime().getGuard().maySeeSensitive()); + + java.util.Map> 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 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 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 matching = new ArrayList<>(); + for(JSONObject row : rows) + { + boolean keep = true; + for(java.util.Map.Entry> 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 membershipComparator(String ordering) + { + java.util.Comparator rs = null; + for(String part : ordering.split(",")) + { + boolean descending = part.startsWith("-"); + String field = descending ? part.substring(1) : part; + java.util.Comparator 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 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 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 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 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 version = fetchServerVersion(); + if(version.isEmpty()) + { + VERSION_CHECKED.set(false); + return; + } + version.ifPresent(actual -> { if(StringUtils.hasContent(expected) && !actual.startsWith(expected + ".") && !actual.equals(expected)) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java index 54af318..4962297 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikApiSurface.java @@ -58,6 +58,17 @@ public final class AuthentikApiSurface { List 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 rs = new ArrayList<>(); for(AuthentikTableSpec spec : AuthentikTables.all().values()) { + if(spec.isVirtual()) + { + continue; + } Set listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering")); for(AuthentikFieldSpec field : spec.getFields()) { diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapper.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapper.java index b802868..7a98fcd 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapper.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikFilterMapper.java @@ -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)); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikJson.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikJson.java index c9a5705..e812a0b 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikJson.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikJson.java @@ -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 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 intAt(String... path) + { + Function 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 malformedEntries(JSONObject object, String key) + { + List 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). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java index 48436f3..8454c6f 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTableSpec.java @@ -45,6 +45,7 @@ public class AuthentikTableSpec private String defaultOrdering; private List 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. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java index d401fd8..dde2c55 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java +++ b/src/main/java/com/kingsrook/qbits/authentik/client/AuthentikTables.java @@ -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); + } + } diff --git a/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java new file mode 100644 index 0000000..9b6d6ca --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReport.java @@ -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 run() + { + List 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 findings) throws QException; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void runCheck(List 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 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 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 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 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 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 findings) throws QException + { + List protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200); + List 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 findings) throws QException + { + List 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 required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE); + Set humans = new HashSet<>(); + for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS)) + { + humans.add(human.getInt("pk")); + } + Set checked = new HashSet<>(); + List 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 findings) throws QException + { + GroupGraph graph = client.groupGraph(); + EffectivePermissionResolver resolver = client.resolver(); + Set 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 findings) throws QException + { + String username = config.getEffectiveServiceAccountUsername(); + List 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.")); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/drift/DriftFinding.java b/src/main/java/com/kingsrook/qbits/authentik/drift/DriftFinding.java new file mode 100644 index 0000000..1820f4b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/drift/DriftFinding.java @@ -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)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java index 4e62537..19c6251 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikAppMetaDataProducer.java @@ -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); } diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java new file mode 100644 index 0000000..bad4d5c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetProducer.java @@ -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 +{ + 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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java new file mode 100644 index 0000000..4b33d25 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikDriftReportWidgetRenderer.java @@ -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 findings = new AuthentikDriftReport(client, runtime.getConfig()).run(); + + List> rows = new ArrayList<>(); + for(DriftFinding finding : findings) + { + Map 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 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)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java new file mode 100644 index 0000000..73bc1b5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikEventTableProducer.java @@ -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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java new file mode 100644 index 0000000..f856f20 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikGroupMemberTableProducer.java @@ -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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java new file mode 100644 index 0000000..1ffec60 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikSessionTableProducer.java @@ -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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java new file mode 100644 index 0000000..a022106 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapper.java @@ -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> 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 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 context) + { + JSONObject claims = context == null ? null : asClaims(context.get(CLAIMS_CONTEXT_KEY)); + if(claims == null) + { + return; + } + List groups = groupNames(claims); + String email = claims.optString("email", "").trim().toLowerCase(); + + Set 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 resolve(QInstance qInstance, Collection groupNames, String email) + { + Set 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 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 groupNames(JSONObject claims) + { + List 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); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java new file mode 100644 index 0000000..f61ad97 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolver.java @@ -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 catalog; + + + + /******************************************************************************* + ** Result for a set of groups. + *******************************************************************************/ + public record Resolution(Set sessionPermissions, Map> grantedBy, Set unknown, Set 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 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 groupPks) + { + Set permissions = new TreeSet<>(); + Map> grantedBy = new LinkedHashMap<>(); + Set unknown = new TreeSet<>(); + Set appAccess = new TreeSet<>(); + + Set 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 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 expand(String raw) + { + Set 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 required) + { + return (required.stream().allMatch(p -> resolution.sessionPermissions().contains(p.getSessionPermission()))); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java b/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java new file mode 100644 index 0000000..b93c4e4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/authentik/permissions/GroupGraph.java @@ -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 parentPks, List permissions, List appAccess, List memberPks, List malformed) + { + } + + + + private final Map byPk = new LinkedHashMap<>(); + private final Map byName = new LinkedHashMap<>(); + private final Map> ancestors = new java.util.concurrent.ConcurrentHashMap<>(); + + + + /******************************************************************************* + ** Build from Authentik group objects (as returned by /core/groups/). + *******************************************************************************/ + public static GroupGraph fromGroups(Collection 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 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 ancestorPks(String pk) + { + Set cached = ancestors.get(pk); + if(cached != null) + { + return (cached); + } + + Set seen = new LinkedHashSet<>(); + Deque 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 immutable = java.util.Collections.unmodifiableSet(seen); + ancestors.put(pk, immutable); + return (immutable); + } + + + + /******************************************************************************* + ** The group and all of its ancestors. + *******************************************************************************/ + public List selfAndAncestors(String pk) + { + List 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 groupsOfUser(int userPk) + { + List rs = new ArrayList<>(); + for(Node node : byPk.values()) + { + if(node.memberPks().contains(userPk)) + { + rs.add(node.pk()); + } + } + return (rs); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static List strings(JSONArray array) + { + List 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 ints(JSONArray array) + { + List 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 malformed(JSONObject attributes) + { + List 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 nonNull(List list) + { + return (list == null ? List.of() : list); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java index 1d71bf7..847bb36 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java +++ b/src/main/java/com/kingsrook/qbits/authentik/security/AuthentikGuard.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java index d421b4d..d26e309 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikQBitProducerTest.java @@ -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"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java b/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java new file mode 100644 index 0000000..b8b989d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/ReviewFixesTest.java @@ -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 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 none = new ArrayList<>(); + AuthentikTestSupport.unitConfig().withBaseUrl(ok).validate(new QInstance(), none); + assertThat(none).as(ok).isEmpty(); + } + List allowed = new ArrayList<>(); + AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.internal").withAllowInsecureHttp(true).validate(new QInstance(), allowed); + assertThat(allowed).isEmpty(); + + List 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 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")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java index 60e0ffd..8e69c1e 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java +++ b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureActionUtil.java @@ -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 REQUESTS = new ArrayList<>(); public static Function 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)); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/FixtureRouter.java b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureRouter.java new file mode 100644 index 0000000..f3877d5 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/client/FixtureRouter.java @@ -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 +{ + public final List users = results("users-list"); + public final List groups = results("groups-list-with-users"); + public final List tokens = results("tokens-list"); + public final List blueprints = results("blueprints-list"); + public final List events = results("events-by-service-account"); + public final List sessions = results("sessions-list"); + + + + /******************************************************************************* + ** Results of a recorded list fixture (mutable copies). + *******************************************************************************/ + public static List results(String fixture) + { + JSONArray array = new JSONObject(FixtureActionUtil.fixture(fixture)).getJSONArray("results"); + List 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 filter(List objects, HttpRequestBase request, String param, String property) + { + String value = param(request, param); + String attrs = param(request, "attributes"); + Predicate 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 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())); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java new file mode 100644 index 0000000..4b8d3b6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/client/Phase1TablesTest.java @@ -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 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 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 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 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 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 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 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); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java index 13ee97b..439b1f6 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java @@ -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"); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/FlowLogin.java b/src/test/java/com/kingsrook/qbits/authentik/contract/FlowLogin.java new file mode 100644 index 0000000..7b00252 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/FlowLogin.java @@ -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 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())); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java new file mode 100644 index 0000000..7cd8647 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java @@ -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 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 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 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 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 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 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 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/")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java b/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java new file mode 100644 index 0000000..8d714d3 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/drift/AuthentikDriftReportTest.java @@ -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 run() throws QException + { + router.refreshGraph(); + return (new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static DriftFinding find(List 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 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 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 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); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java new file mode 100644 index 0000000..758e3b1 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/AuthentikGroupPermissionMapperTest.java @@ -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 context(String email, Object groups) + { + Map 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(); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java new file mode 100644 index 0000000..c1c27d7 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/permissions/EffectivePermissionResolverTest.java @@ -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 parents, List permissions, List appAccess, List 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 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(); + } + +} diff --git a/src/test/resources/fixtures/2026.8.3/admin-version.json b/src/test/resources/fixtures/2026.8.3/admin-version.json index 022b6a6..30e3e95 100644 --- a/src/test/resources/fixtures/2026.8.3/admin-version.json +++ b/src/test/resources/fixtures/2026.8.3/admin-version.json @@ -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": "" } diff --git a/src/test/resources/fixtures/2026.8.3/blueprints-list.json b/src/test/resources/fixtures/2026.8.3/blueprints-list.json new file mode 100644 index 0000000..005c741 --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/blueprints-list.json @@ -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" + } + ] +} diff --git a/src/test/resources/fixtures/2026.8.3/events-by-service-account.json b/src/test/resources/fixtures/2026.8.3/events-by-service-account.json new file mode 100644 index 0000000..7eb3dc9 --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/events-by-service-account.json @@ -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" + } + } + ] +} diff --git a/src/test/resources/fixtures/2026.8.3/events-list.json b/src/test/resources/fixtures/2026.8.3/events-list.json new file mode 100644 index 0000000..8ecee59 --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/events-list.json @@ -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" + } + } + ] +} diff --git a/src/test/resources/fixtures/2026.8.3/group-with-users.json b/src/test/resources/fixtures/2026.8.3/group-with-users.json new file mode 100644 index 0000000..9e2eeca --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/group-with-users.json @@ -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"] + }] +} diff --git a/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json b/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json new file mode 100644 index 0000000..1dc3223 --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/groups-list-with-users.json @@ -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"] + } + ] +} diff --git a/src/test/resources/fixtures/2026.8.3/groups-list.json b/src/test/resources/fixtures/2026.8.3/groups-list.json index 066c672..660d9da 100644 --- a/src/test/resources/fixtures/2026.8.3/groups-list.json +++ b/src/test/resources/fixtures/2026.8.3/groups-list.json @@ -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": [] }] diff --git a/src/test/resources/fixtures/2026.8.3/sessions-list.json b/src/test/resources/fixtures/2026.8.3/sessions-list.json new file mode 100644 index 0000000..f61a286 --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/sessions-list.json @@ -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" + } + } + }] +} diff --git a/src/test/resources/fixtures/2026.8.3/tokens-list.json b/src/test/resources/fixtures/2026.8.3/tokens-list.json new file mode 100644 index 0000000..b047acc --- /dev/null +++ b/src/test/resources/fixtures/2026.8.3/tokens-list.json @@ -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 + }] +} diff --git a/src/test/resources/fixtures/2026.8.3/user-get.json b/src/test/resources/fixtures/2026.8.3/user-get.json index 181be83..65eace0 100644 --- a/src/test/resources/fixtures/2026.8.3/user-get.json +++ b/src/test/resources/fixtures/2026.8.3/user-get.json @@ -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" } diff --git a/src/test/resources/fixtures/2026.8.3/users-list.json b/src/test/resources/fixtures/2026.8.3/users-list.json index 6be3b65..0923b85 100644 --- a/src/test/resources/fixtures/2026.8.3/users-list.json +++ b/src/test/resources/fixtures/2026.8.3/users-list.json @@ -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" } ] }