Phase 1: read-only MVP (memberships, sessions, events, effective permissions, drift report, mapper) #4
No files matched your search
@@ -32,9 +32,11 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- Writes go to the guard, which refuses them in 0.1.
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups). `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
|
||||
+21
-1
@@ -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.
|
||||
@@ -32,9 +32,11 @@ CI=true ./mvnw verify # + contract ITs: Authentik 2026.8.3 server+worker, Post
|
||||
- Writes go to the guard, which refuses them in 0.1.
|
||||
- `execute()` is the single request loop: throttle, bearer header, 429/5xx retry, error mapping, logging.
|
||||
- `send()` is the test seam (`FixtureActionUtil`, `QbitReadContractIT.RecordingActionUtil`).
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups). `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikTables` maps tables to the pinned schema (users, groups, sessions, events), plus the **virtual** `groupMember` table, which `AuthentikActionUtil.memberships()` serves from a group, a user, or a bounded sweep. `client/AuthentikApiSurface` lists every operation and parameter used. `SchemaDriftTest` and `SchemaDriftIT` check that surface.
|
||||
- `client/AuthentikFilterMapper` maps a QQQ filter to Authentik params, or rejects it with a `QUserFacingException`. It never filters client-side.
|
||||
- `security/AuthentikGuard` handles read and write permission checks, the user scope, and protected-account detection. `security/IdentityPermission` is the `identity.*` catalog (session form `<name>.hasAccess`).
|
||||
- `permissions/GroupGraph` + `EffectivePermissionResolver` (pure) turn groups and their ancestors into QQQ permissions. The util caches the graph for `groupCacheTtlSeconds` and uses it for derived fields.
|
||||
- `drift/AuthentikDriftReport` holds the drift checks, using only the service account's read permissions. It is rendered by `metadata/AuthentikDriftReportWidgetRenderer`, a standard TABLE widget gated by `identity.admin`.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -33,7 +33,7 @@ The agent cannot read production data. The rehearsal runs entirely in local Dock
|
||||
git -C cluster-gitops show origin/main:tenants/kof22/website-authentik/blueprint-configmap.yaml > before.yaml
|
||||
git -C cluster-gitops show origin/feat/kof22-identity-bootstrap:tenants/kof22/website-authentik/blueprint-configmap.yaml > after.yaml
|
||||
```
|
||||
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml`.
|
||||
3. **Run** `docs/cluster-gitops-phase0/rehearse-phase0.sh authentik-<date>.dump before.yaml after.yaml` from macOS, Linux or Git Bash. The script has already passed on a synthetic production-like dump: today's `main` blueprint with James re-pinned by the `present` entry, plus UI-made publishers.
|
||||
- The script restores the dump into a throwaway Postgres and starts Authentik 2026.8.3 on the current blueprints. It then records every user's groups, switches to the phase 0 blueprints, re-applies both twice, and checks:
|
||||
- nobody's memberships changed
|
||||
- `james.maes` keeps `authentik Admins` and `kof22-admins`
|
||||
|
||||
+20
-1
@@ -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).
|
||||
@@ -13,11 +13,13 @@
|
||||
#
|
||||
# Needs: docker, jq, python3 with PyYAML. Takes ~5 minutes.
|
||||
set -euo pipefail
|
||||
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep container paths as-is
|
||||
|
||||
DUMP=${1:?dump file}; BEFORE=${2:?configmap before}; AFTER=${3:?configmap after}
|
||||
IMAGE=${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server:2026.8.3}
|
||||
NET=ak-rehearsal; PORT=${PORT:-19900}
|
||||
WORK=$(mktemp -d); BP="$WORK/blueprints"; mkdir -p "$BP"
|
||||
BP_MOUNT="$BP"; command -v cygpath >/dev/null 2>&1 && BP_MOUNT=$(cygpath -w "$BP")
|
||||
ADMIN_TOKEN="rehearsal-$(openssl rand -hex 16)"
|
||||
API="http://localhost:$PORT/api/v3"
|
||||
|
||||
@@ -26,7 +28,7 @@ trap cleanup EXIT
|
||||
|
||||
extract() { # write every blueprint key of a ConfigMap into $BP
|
||||
rm -f "$BP"/*.yaml
|
||||
python3 - "$1" "$BP" <<'PY'
|
||||
python3 - "$1" "$BP_MOUNT" <<'PY'
|
||||
import sys, yaml
|
||||
doc = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
|
||||
for key, value in doc["data"].items():
|
||||
@@ -38,12 +40,14 @@ PY
|
||||
api() { curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" "$API$1"; }
|
||||
snapshot() { # users -> sorted groups, as TSV
|
||||
api "/core/users/?page_size=100&include_groups=true&type=internal&type=external&type=service_account" \
|
||||
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | sort
|
||||
| jq -r '.results[] | [.username, ([.groups_obj[].name] | sort | join(","))] | @tsv' | tr -d '\r' | sort
|
||||
}
|
||||
wait_blueprints() {
|
||||
wait_blueprints() { # $1 = how many kof22* blueprint instances must exist
|
||||
local want=${1:-1}
|
||||
for _ in $(seq 1 90); do
|
||||
states=$(api "/managed/blueprints/?page_size=100" | jq -r '[.results[] | select(.name|startswith("kof22")) | .status] | join(" ")' || true)
|
||||
if [ -n "$states" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
|
||||
count=$(wc -w <<<"$states")
|
||||
if [ "$count" -ge "$want" ] && ! grep -qv successful <<<"$(tr ' ' '\n' <<<"$states")"; then echo " kof22 blueprints: $states"; return 0; fi
|
||||
sleep 5
|
||||
done
|
||||
echo " blueprints did not all reach successful: $states"; api "/managed/blueprints/?page_size=100" | jq -r '.results[] | "\(.name) \(.status)"'; return 1
|
||||
@@ -54,7 +58,8 @@ docker network create $NET >/dev/null
|
||||
docker run -d --name akr-pg --network $NET -e POSTGRES_USER=authentik -e POSTGRES_PASSWORD=rehearsal -e POSTGRES_DB=authentik postgres:16-alpine >/dev/null
|
||||
until docker exec akr-pg pg_isready -U authentik >/dev/null 2>&1; do sleep 1; done
|
||||
docker cp "$DUMP" akr-pg:/tmp/authentik.dump
|
||||
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || true
|
||||
docker exec akr-pg pg_restore -U authentik -d authentik --no-owner --no-privileges /tmp/authentik.dump || echo " (pg_restore reported warnings; usually role/ownership notices)"
|
||||
docker exec akr-pg psql -U authentik -d authentik -tAc "select count(*) from authentik_core_user" | sed 's/^/ users restored: /'
|
||||
|
||||
echo "== 2. start Authentik with the CURRENT (main) blueprints"
|
||||
extract "$BEFORE"
|
||||
@@ -62,33 +67,33 @@ ENV=(-e AUTHENTIK_SECRET_KEY="$(openssl rand -hex 32)" -e AUTHENTIK_POSTGRESQL__
|
||||
-e AUTHENTIK_POSTGRESQL__PASSWORD=rehearsal -e AUTHENTIK_POSTGRESQL__NAME=authentik -e AUTHENTIK_BOOTSTRAP_TOKEN="$ADMIN_TOKEN"
|
||||
-e KOF22_OAUTH2_CLIENT_SECRET=rehearsal-not-the-real-secret -e AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
||||
-e AUTHENTIK_DISABLE_UPDATE_CHECK=true -e AUTHENTIK_EMAIL__HOST=localhost -e AUTHENTIK_EMAIL__PORT=1)
|
||||
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP:/blueprints/custom" "$IMAGE" worker >/dev/null
|
||||
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP:/blueprints/custom" "$IMAGE" server >/dev/null
|
||||
docker run -d --name akr-worker --network $NET "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" worker >/dev/null
|
||||
docker run -d --name akr-server --network $NET -p "$PORT:9000" "${ENV[@]}" -v "$BP_MOUNT:/blueprints/custom" "$IMAGE" server >/dev/null
|
||||
until curl -sf "http://localhost:$PORT/-/health/ready/" >/dev/null; do sleep 3; done
|
||||
# The bootstrap token only applies to an EMPTY instance; on a restored database
|
||||
# create a rehearsal token for akadmin-equivalent access through the shell.
|
||||
docker exec akr-worker ak shell -c "
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
u = User.objects.filter(is_superuser=True, is_active=True).exclude(username__startswith='ak-').order_by('pk').first()
|
||||
u = next(x for x in User.objects.filter(is_active=True).exclude(username__startswith='ak-').order_by('pk') if x.is_superuser) # is_superuser is derived from groups
|
||||
Token.objects.update_or_create(identifier='phase0-rehearsal', defaults={'user': u, 'intent': TokenIntents.INTENT_API, 'key': '$ADMIN_TOKEN', 'expiring': False})
|
||||
print('rehearsal token for', u.username)" 2>/dev/null | grep rehearsal
|
||||
wait_blueprints
|
||||
print('rehearsal token for', u.username)" 2>&1 | grep -E 'rehearsal token|Error|error' | tail -3 | grep 'rehearsal token' || { echo "could not create the rehearsal API token in the restored copy"; exit 1; }
|
||||
wait_blueprints 1
|
||||
snapshot > "$WORK/before.tsv"; echo " $(wc -l < "$WORK/before.tsv") users recorded (step 1, freeze)"
|
||||
|
||||
echo "== 3. switch to the phase 0 blueprints (as an ArgoCD sync would)"
|
||||
extract "$AFTER"
|
||||
docker restart akr-worker >/dev/null # a restart triggers discovery like a ConfigMap change
|
||||
wait_blueprints
|
||||
wait_blueprints 2
|
||||
for name in kof22-oauth2-apps kof22-identity-bootstrap; do
|
||||
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
|
||||
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
|
||||
done
|
||||
sleep 30; wait_blueprints
|
||||
sleep 30; wait_blueprints 2
|
||||
|
||||
echo "== 4. checks"
|
||||
snapshot > "$WORK/after.tsv"
|
||||
fail=0
|
||||
if ! diff <(grep -v -E '^(qqq-kof22-admin-idadmin|kof22-breakglass)\b' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
|
||||
if ! diff <(awk -F'\t' '$1 != "qqq-kof22-admin-idadmin" && $1 != "kof22-breakglass"' "$WORK/after.tsv") "$WORK/before.tsv" >/dev/null; then
|
||||
echo " FAIL memberships changed:"; diff "$WORK/before.tsv" "$WORK/after.tsv" || true; fail=1
|
||||
else echo " ok existing users' memberships unchanged"; fi
|
||||
grep -q $'^james.maes\t.*authentik Admins.*kof22-admins' "$WORK/after.tsv" && echo " ok james.maes keeps authentik Admins + kof22-admins" || { echo " FAIL james.maes groups"; fail=1; }
|
||||
@@ -103,6 +108,6 @@ for name in kof22-oauth2-apps kof22-identity-bootstrap; do
|
||||
pk=$(api "/managed/blueprints/?name=$name" | jq -r '.results[0].pk')
|
||||
curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" "$API/managed/blueprints/$pk/apply/" >/dev/null
|
||||
done
|
||||
sleep 30; wait_blueprints
|
||||
sleep 30; wait_blueprints 2
|
||||
snapshot | diff - "$WORK/after.tsv" >/dev/null && echo " ok stable across re-apply" || { echo " FAIL changed on re-apply"; fail=1; }
|
||||
[ $fail = 0 ] && echo "REHEARSAL PASSED" || { echo "REHEARSAL FAILED"; exit 1; }
|
||||
@@ -75,6 +75,7 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private Integer connectTimeoutMillis = 5_000;
|
||||
private Integer socketTimeoutMillis = 30_000;
|
||||
private String expectedServerVersion = "2026.8";
|
||||
private Boolean allowInsecureHttp = false;
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +99,10 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("baseUrl must be an absolute http(s) URL: " + baseUrl);
|
||||
}
|
||||
else if(uri.getScheme().equals("http") && !Boolean.TRUE.equals(allowInsecureHttp) && !isLoopback(uri.getHost()))
|
||||
{
|
||||
errors.add("baseUrl must use https (the API token would travel in cleartext); http is allowed only for localhost or with allowInsecureHttp: " + baseUrl);
|
||||
}
|
||||
else if(uri.getPath() != null && uri.getPath().contains("/api/v3"))
|
||||
{
|
||||
errors.add("baseUrl must be the host root; /api/v3 is appended by the qbit: " + baseUrl);
|
||||
@@ -124,6 +129,11 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
errors.add("tableNamePrefix must start with a letter and contain only letters and digits: " + tableNamePrefix);
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(groupScopeNamePrefix))
|
||||
{
|
||||
errors.add("groupScopeNamePrefix is not available in this release of qbit-authentik");
|
||||
}
|
||||
|
||||
Set<AuthentikFeature> requested = features == null ? EnumSet.noneOf(AuthentikFeature.class) : features;
|
||||
if(!requested.contains(AuthentikFeature.READ))
|
||||
{
|
||||
@@ -175,6 +185,38 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isLoopback(String host)
|
||||
{
|
||||
return ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "[::1]".equals(host) || "::1".equals(host));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowInsecureHttp
|
||||
*******************************************************************************/
|
||||
public Boolean getAllowInsecureHttp()
|
||||
{
|
||||
return (this.allowInsecureHttp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowInsecureHttp (plain http to a non-loopback host;
|
||||
** for tests and private networks only)
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withAllowInsecureHttp(Boolean allowInsecureHttp)
|
||||
{
|
||||
this.allowInsecureHttp = allowInsecureHttp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefix a component name with the tableNamePrefix (camel-cased join).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.TokenBucketThrottle;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
@@ -42,6 +43,8 @@ public class AuthentikRuntime
|
||||
private final AuthentikGuard guard;
|
||||
private final AuthentikFilterMapper filterMapper = new AuthentikFilterMapper();
|
||||
private final Memoization<String, String> responseCache;
|
||||
private final Memoization<String, GroupGraph> groupGraphCache;
|
||||
private volatile java.util.Set<String> permissionCatalog;
|
||||
private final Map<String, AuthentikTableSpec> specsByTableName = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@@ -55,6 +58,7 @@ public class AuthentikRuntime
|
||||
this.throttle = new TokenBucketThrottle(config.getThrottleRequestsPerSecond(), config.getThrottleBurst());
|
||||
this.guard = new AuthentikGuard(config);
|
||||
this.responseCache = new Memoization<String, String>(Duration.ofSeconds(config.getListCacheTtlSeconds())).withMaxSize(500);
|
||||
this.groupGraphCache = new Memoization<String, GroupGraph>(Duration.ofSeconds(config.getGroupCacheTtlSeconds())).withMaxSize(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +125,34 @@ public class AuthentikRuntime
|
||||
public void invalidateCache()
|
||||
{
|
||||
responseCache.clear();
|
||||
groupGraphCache.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache for the group graph (groupCacheTtlSeconds; 0 disables).
|
||||
*******************************************************************************/
|
||||
public Memoization<String, GroupGraph> getGroupGraphCache()
|
||||
{
|
||||
return (groupGraphCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ permission names that qqq.io/permissions may grant: every permission
|
||||
** the instance's metadata defines (PermissionsHelper), computed once.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> getPermissionCatalog()
|
||||
{
|
||||
if(permissionCatalog == null)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
permissionCatalog = qInstance == null ? java.util.Set.of()
|
||||
: new java.util.TreeSet<>(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance));
|
||||
}
|
||||
return (permissionCatalog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import com.kingsrook.qqq.backend.module.api.model.OutboundAPILog;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.apache.http.Header;
|
||||
import org.apache.http.HttpEntityEnclosingRequest;
|
||||
import org.apache.http.NameValuePair;
|
||||
@@ -98,6 +102,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
private static final AtomicBoolean VERSION_CHECKED = new AtomicBoolean(false);
|
||||
|
||||
/** Maximum memberships / groups read by one sweep (bounded, never silently: see memberships()). */
|
||||
public static final int MAX_SWEEP_OBJECTS = 20_000;
|
||||
|
||||
/** Display value for derived fields when the group graph cannot be read. */
|
||||
public static final String UNAVAILABLE = "Unavailable (Authentik groups could not be read)";
|
||||
|
||||
private EffectivePermissionResolver resolverForCall;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client for a qbit API backend of the current QInstance, for code that
|
||||
** is not a table action (widgets, the drift report). Honours the backend's
|
||||
** configured action util class (tests swap it).
|
||||
*******************************************************************************/
|
||||
public static AuthentikActionUtil forBackend(String backendName) throws QException
|
||||
{
|
||||
if(QContext.getQInstance() == null || !(QContext.getQInstance().getBackend(backendName) instanceof APIBackendMetaData backend))
|
||||
{
|
||||
throw (new QException("No Authentik API backend named " + backendName));
|
||||
}
|
||||
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
|
||||
util.setBackendMetaData(backend);
|
||||
return ((AuthentikActionUtil) util);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -134,8 +164,14 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
AuthentikTableSpec spec = spec(table);
|
||||
runtime().getGuard().checkRead(spec);
|
||||
|
||||
CountOutput output = new CountOutput();
|
||||
if(spec.isVirtual())
|
||||
{
|
||||
output.setCount(memberships(spec, countInput.getFilter()).size());
|
||||
return (output);
|
||||
}
|
||||
|
||||
AuthentikFilterMapper.MappedFilter mapped = map(spec, countInput.getFilter());
|
||||
CountOutput output = new CountOutput();
|
||||
if(mapped.isKeyLookup())
|
||||
{
|
||||
output.setCount(fetchByKeys(table, spec, mapped.keyLookup(), null).size());
|
||||
@@ -143,7 +179,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
|
||||
JSONObject page = getJson(listUrl(spec, mapped, 1, 1));
|
||||
output.setCount(page.optJSONObject("pagination") == null ? 0 : page.getJSONObject("pagination").optInt("count", 0));
|
||||
output.setCount(pagination(page).optInt("count", 0));
|
||||
return (output);
|
||||
}
|
||||
|
||||
@@ -164,6 +200,17 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
throw (new QUserFacingException(spec.getLabel() + " can only be fetched by id."));
|
||||
}
|
||||
|
||||
if(spec.isVirtual())
|
||||
{
|
||||
List<JSONObject> rows = memberships(spec, new QQueryFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria(spec.getKeyFieldName(),
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, getInput.getPrimaryKey())));
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
output.setRecord(toRecord(spec, rows.get(0), runtime().getGuard().maySeeSensitive()));
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
JSONObject object = getObject(spec, getInput.getPrimaryKey());
|
||||
if(object != null && isVisible(spec, object))
|
||||
{
|
||||
@@ -183,14 +230,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
AuthentikTableSpec spec = spec(table);
|
||||
runtime().getGuard().checkRead(spec);
|
||||
|
||||
QueryOutput output = new QueryOutput(queryInput);
|
||||
QQueryFilter filter = queryInput.getFilter();
|
||||
AuthentikFilterMapper.MappedFilter mapped = map(spec, filter);
|
||||
boolean sensitive = runtime().getGuard().maySeeSensitive();
|
||||
QueryOutput output = new QueryOutput(queryInput);
|
||||
QQueryFilter filter = queryInput.getFilter();
|
||||
boolean sensitive = runtime().getGuard().maySeeSensitive();
|
||||
|
||||
Integer limit = filter == null ? null : filter.getLimit();
|
||||
int skip = filter == null || filter.getSkip() == null ? 0 : filter.getSkip();
|
||||
|
||||
if(limit != null && limit <= 0)
|
||||
{
|
||||
return (output);
|
||||
}
|
||||
|
||||
if(spec.isVirtual())
|
||||
{
|
||||
List<JSONObject> rows = memberships(spec, filter);
|
||||
int added = 0;
|
||||
for(int i = skip; i < rows.size() && (limit == null || added < limit); i++)
|
||||
{
|
||||
output.addRecord(toRecord(spec, rows.get(i), sensitive));
|
||||
added++;
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
AuthentikFilterMapper.MappedFilter mapped = map(spec, filter);
|
||||
|
||||
if(mapped.isKeyLookup())
|
||||
{
|
||||
List<JSONObject> objects = fetchByKeys(table, spec, mapped.keyLookup(), limit);
|
||||
@@ -245,8 +310,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
offset = 0;
|
||||
|
||||
JSONObject pagination = response.optJSONObject("pagination");
|
||||
int totalPages = pagination == null ? page : pagination.optInt("total_pages", page);
|
||||
int totalPages = pagination(response).optInt("total_pages", page);
|
||||
if(page >= totalPages || queryInput.getAsyncJobCallback().wasCancelRequested())
|
||||
{
|
||||
return (output);
|
||||
@@ -300,7 +364,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public JSONObject getObject(AuthentikTableSpec spec, Serializable key) throws QException
|
||||
{
|
||||
String keyText = String.valueOf(key).trim();
|
||||
if(keyText.isEmpty() || !keyText.matches("[A-Za-z0-9._:-]+"))
|
||||
if(keyText.isEmpty() || !keyText.matches("[A-Za-z0-9._:-]+") || keyText.contains(".."))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
@@ -354,8 +418,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
rs.add(results.getJSONObject(i));
|
||||
}
|
||||
JSONObject pagination = response.optJSONObject("pagination");
|
||||
if(rs.size() >= maxObjects || pagination == null || page >= pagination.optInt("total_pages", page))
|
||||
if(rs.size() >= maxObjects || page >= pagination(response).optInt("total_pages", page))
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
@@ -422,6 +485,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
if(AuthentikTables.USER.equals(spec.getName()))
|
||||
{
|
||||
checkWithinUserScope(mapped.params());
|
||||
addUserScope(builder, usedNames);
|
||||
}
|
||||
builder.addParameter("page", String.valueOf(page));
|
||||
@@ -441,6 +505,31 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
** the operator set on the same parameter wins server-side; the per-record
|
||||
** check still enforces the scope.
|
||||
*******************************************************************************/
|
||||
private void checkWithinUserScope(List<NameValuePair> params) throws QUserFacingException
|
||||
{
|
||||
String pathPrefix = runtime().getConfig().getUserScopePathStartsWith();
|
||||
List<String> scopeGroups = runtime().getConfig().getUserScopeGroupNames() == null ? List.of() : runtime().getConfig().getUserScopeGroupNames();
|
||||
for(NameValuePair param : params)
|
||||
{
|
||||
boolean outside = switch(param.getName())
|
||||
{
|
||||
case "type" -> !List.of(AuthentikTables.DEFAULT_USER_TYPES).contains(param.getValue());
|
||||
case "path", "path_startswith" -> StringUtils.hasContent(pathPrefix) && !param.getValue().startsWith(pathPrefix);
|
||||
case "groups_by_name" -> !scopeGroups.isEmpty() && !scopeGroups.contains(param.getValue());
|
||||
default -> false;
|
||||
};
|
||||
if(outside)
|
||||
{
|
||||
throw (new QUserFacingException("That filter (" + param.getName() + " = " + param.getValue() + ") is outside the users this app may see."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope parameters (the operator's narrower filter, checked above, wins).
|
||||
*******************************************************************************/
|
||||
private void addUserScope(URIBuilder builder, List<String> usedNames)
|
||||
{
|
||||
if(!usedNames.contains("type"))
|
||||
@@ -527,6 +616,37 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
record.setDisplayValue("isProtected", "Protected: " + protection.reason());
|
||||
}
|
||||
|
||||
EffectivePermissionResolver resolver = resolverForCall();
|
||||
if(resolver != null)
|
||||
{
|
||||
List<String> groupPks = new ArrayList<>();
|
||||
JSONArray groups = object.optJSONArray("groups_obj");
|
||||
for(int i = 0; groups != null && i < groups.length(); i++)
|
||||
{
|
||||
groupPks.add(groups.getJSONObject(i).optString("pk"));
|
||||
}
|
||||
record.setValue("effectivePermissions", String.join(", ", resolver.resolveGroups(groupPks).sessionPermissions()));
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setDisplayValue("effectivePermissions", UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
else if(AuthentikTables.GROUP.equals(spec.getName()))
|
||||
{
|
||||
EffectivePermissionResolver resolver = resolverForCall();
|
||||
if(resolver != null)
|
||||
{
|
||||
String pk = object.optString("pk");
|
||||
record.setValue("isSuperuserInherited", resolverGraph.isSuperuserInherited(pk));
|
||||
record.setValue("effectivePermissions", String.join(", ", resolver.resolveGroups(List.of(pk)).sessionPermissions()));
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setDisplayValue("effectivePermissions", UNAVAILABLE);
|
||||
record.setDisplayValue("isSuperuserInherited", UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
@@ -538,6 +658,310 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current group graph: every group with parents, attributes and member
|
||||
** pks (cached for groupCacheTtlSeconds).
|
||||
*******************************************************************************/
|
||||
public GroupGraph groupGraph() throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
if(cache)
|
||||
{
|
||||
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
|
||||
if(cached.isPresent())
|
||||
{
|
||||
return (cached.get());
|
||||
}
|
||||
}
|
||||
|
||||
List<JSONObject> groups = getAllPages("/core/groups/?include_users=false&include_parents=false&include_children=false&ordering=name", MAX_SWEEP_OBJECTS);
|
||||
if(groups.size() >= MAX_SWEEP_OBJECTS)
|
||||
{
|
||||
throw (new QUserFacingException("More than " + MAX_SWEEP_OBJECTS + " Authentik groups; the group graph is not supported at this size."));
|
||||
}
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
if(cache)
|
||||
{
|
||||
runtime.getGroupGraphCache().storeResult("all", graph);
|
||||
}
|
||||
return (graph);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolver over the current group graph and the instance's permissions.
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver resolver() throws QException
|
||||
{
|
||||
return (new EffectivePermissionResolver(groupGraph(), runtime().getPermissionCatalog()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
private GroupGraph resolverGraph;
|
||||
private boolean resolverFailed;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The resolver for this call (built once per util instance); null when the
|
||||
** group graph cannot be read, in which case derived fields stay empty.
|
||||
*******************************************************************************/
|
||||
private EffectivePermissionResolver resolverForCall()
|
||||
{
|
||||
if(resolverForCall == null && !resolverFailed)
|
||||
{
|
||||
try
|
||||
{
|
||||
resolverGraph = groupGraph();
|
||||
resolverForCall = new EffectivePermissionResolver(resolverGraph, runtime().getPermissionCatalog());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
resolverFailed = true;
|
||||
LOG.warn("Group graph unavailable; effective permissions shown as unavailable", logPair("error", AuthentikRedactor.redactText(String.valueOf(e.getMessage()), token())));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
return (resolverForCall);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of the virtual membership table for a filter. Criteria: groupPk
|
||||
** (equals / in), groupName, userPk, username (equals), id (key lookup);
|
||||
** AND only. The narrowest criterion picks the source (a group, a user, or a
|
||||
** sweep of all groups); all criteria are then applied to the rows, which
|
||||
** are sorted by the requested ordering (default group, then username).
|
||||
*******************************************************************************/
|
||||
public List<JSONObject> memberships(AuthentikTableSpec spec, QQueryFilter filter) throws QException
|
||||
{
|
||||
AuthentikFilterMapper.MappedFilter mapped = runtime().getFilterMapper().map(spec, filter, runtime().getGuard().maySeeSensitive());
|
||||
|
||||
java.util.Map<String, List<String>> wanted = new java.util.LinkedHashMap<>();
|
||||
String ordering = "groupName";
|
||||
for(NameValuePair param : mapped.params())
|
||||
{
|
||||
if(param.getName().equals("ordering"))
|
||||
{
|
||||
ordering = param.getValue();
|
||||
}
|
||||
else
|
||||
{
|
||||
wanted.computeIfAbsent(param.getName(), k -> new ArrayList<>()).add(param.getValue());
|
||||
}
|
||||
}
|
||||
if(mapped.isKeyLookup())
|
||||
{
|
||||
for(Serializable key : mapped.keyLookup())
|
||||
{
|
||||
String[] parts = String.valueOf(key).split(":", 2);
|
||||
if(parts.length == 2)
|
||||
{
|
||||
wanted.computeIfAbsent("groupPk", k -> new ArrayList<>()).add(parts[0]);
|
||||
wanted.computeIfAbsent("id", k -> new ArrayList<>()).add(String.valueOf(key));
|
||||
}
|
||||
}
|
||||
if(!wanted.containsKey("id"))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
List<JSONObject> rows = new ArrayList<>();
|
||||
if(wanted.containsKey("groupPk"))
|
||||
{
|
||||
for(String groupPk : new java.util.LinkedHashSet<>(wanted.get("groupPk")))
|
||||
{
|
||||
JSONObject group = groupPk.matches("[A-Za-z0-9-]+") ? getJsonOrNull(runtime().getConfig().getApiRoot() + "/core/groups/" + groupPk + "/?include_users=true") : null;
|
||||
addGroupRows(rows, group);
|
||||
}
|
||||
}
|
||||
else if(wanted.containsKey("groupName"))
|
||||
{
|
||||
JSONArray results = getApiJson("/core/groups/?include_users=true&name=" + encode(wanted.get("groupName").get(0))).optJSONArray("results");
|
||||
addGroupRows(rows, results == null || results.isEmpty() ? null : results.getJSONObject(0));
|
||||
}
|
||||
else if(wanted.containsKey("userPk") || wanted.containsKey("username"))
|
||||
{
|
||||
JSONObject user;
|
||||
if(wanted.containsKey("userPk"))
|
||||
{
|
||||
String userPk = wanted.get("userPk").get(0);
|
||||
user = userPk.matches("[0-9]+") ? getJsonOrNull(runtime().getConfig().getApiRoot() + "/core/users/" + userPk + "/") : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
JSONArray results = getApiJson("/core/users/?include_groups=true&username=" + encode(wanted.get("username").get(0))).optJSONArray("results");
|
||||
user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
|
||||
}
|
||||
addUserRows(rows, user);
|
||||
}
|
||||
else
|
||||
{
|
||||
List<JSONObject> groups = getAllPages("/core/groups/?include_users=true&ordering=name", MAX_SWEEP_OBJECTS);
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
addGroupRows(rows, group);
|
||||
}
|
||||
if(rows.size() >= MAX_SWEEP_OBJECTS || groups.size() >= MAX_SWEEP_OBJECTS)
|
||||
{
|
||||
throw (new QUserFacingException("Too many memberships to list without a filter; filter by group or user."));
|
||||
}
|
||||
}
|
||||
|
||||
List<JSONObject> matching = new ArrayList<>();
|
||||
for(JSONObject row : rows)
|
||||
{
|
||||
boolean keep = true;
|
||||
for(java.util.Map.Entry<String, List<String>> entry : wanted.entrySet())
|
||||
{
|
||||
if(!entry.getValue().contains(String.valueOf(row.opt(entry.getKey()))))
|
||||
{
|
||||
keep = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(keep && isMemberInScope(row))
|
||||
{
|
||||
matching.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
matching.sort(membershipComparator(ordering));
|
||||
return (matching);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static java.util.Comparator<JSONObject> membershipComparator(String ordering)
|
||||
{
|
||||
java.util.Comparator<JSONObject> rs = null;
|
||||
for(String part : ordering.split(","))
|
||||
{
|
||||
boolean descending = part.startsWith("-");
|
||||
String field = descending ? part.substring(1) : part;
|
||||
java.util.Comparator<JSONObject> one = java.util.Comparator.comparing(o -> o.optString(field, ""), String.CASE_INSENSITIVE_ORDER);
|
||||
if(descending)
|
||||
{
|
||||
one = one.reversed();
|
||||
}
|
||||
rs = rs == null ? one : rs.thenComparing(one);
|
||||
}
|
||||
java.util.Comparator<JSONObject> tieBreak = java.util.Comparator.comparing(o -> o.optString("groupName", "") + "\u0000" + o.optString("username", ""));
|
||||
return (rs == null ? tieBreak : rs.thenComparing(tieBreak));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows for every member of a group (from users_obj).
|
||||
*******************************************************************************/
|
||||
private static void addGroupRows(List<JSONObject> rows, JSONObject group)
|
||||
{
|
||||
if(group == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JSONArray users = group.optJSONArray("users_obj");
|
||||
for(int i = 0; users != null && i < users.length(); i++)
|
||||
{
|
||||
JSONObject user = users.getJSONObject(i);
|
||||
rows.add(membershipRow(group.optString("pk"), group.optString("name"), user));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows for every group of a user (from groups_obj).
|
||||
*******************************************************************************/
|
||||
private static void addUserRows(List<JSONObject> rows, JSONObject user)
|
||||
{
|
||||
if(user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JSONArray groups = user.optJSONArray("groups_obj");
|
||||
for(int i = 0; groups != null && i < groups.length(); i++)
|
||||
{
|
||||
JSONObject group = groups.getJSONObject(i);
|
||||
rows.add(membershipRow(group.optString("pk"), group.optString("name"), user));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject membershipRow(String groupPk, String groupName, JSONObject user)
|
||||
{
|
||||
return (new JSONObject()
|
||||
.put("id", groupPk + ":" + user.optInt("pk"))
|
||||
.put("groupPk", groupPk)
|
||||
.put("groupName", groupName)
|
||||
.put("userPk", user.optInt("pk"))
|
||||
.put("username", user.optString("username"))
|
||||
.put("name", user.optString("name"))
|
||||
.put("email", user.optString("email"))
|
||||
.put("isActive", user.optBoolean("is_active", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Membership rows carry partial users (no type or path), so with a user
|
||||
** scope configured each member is checked through a (cached) user GET.
|
||||
*******************************************************************************/
|
||||
private boolean isMemberInScope(JSONObject row) throws QException
|
||||
{
|
||||
if(scopedUserPks == null)
|
||||
{
|
||||
scopedUserPks = new java.util.HashSet<>();
|
||||
try
|
||||
{
|
||||
URIBuilder builder = new URIBuilder("/core/users/");
|
||||
addUserScope(builder, List.of());
|
||||
String query = builder.build().getRawQuery();
|
||||
for(JSONObject user : getAllPages("/core/users/?" + query, MAX_SWEEP_OBJECTS))
|
||||
{
|
||||
if(runtime().getGuard().isUserInScope(user))
|
||||
{
|
||||
scopedUserPks.add(user.getInt("pk"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.net.URISyntaxException e)
|
||||
{
|
||||
throw (new QException("Bad Authentik URL", e));
|
||||
}
|
||||
}
|
||||
return (scopedUserPks.contains(row.optInt("userPk")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
private java.util.Set<Integer> scopedUserPks;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (java.net.URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET JSON through the response cache.
|
||||
*******************************************************************************/
|
||||
@@ -549,6 +973,32 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pagination object of a list response; a list response without one
|
||||
** means the API changed, which must not read as "no results".
|
||||
*******************************************************************************/
|
||||
static JSONObject pagination(JSONObject response) throws QException
|
||||
{
|
||||
JSONObject pagination = response.optJSONObject("pagination");
|
||||
if(pagination == null)
|
||||
{
|
||||
throw (new QException("Unexpected Authentik list response (no pagination object); has the API changed?"));
|
||||
}
|
||||
return (pagination);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET an API path (relative to /api/v3) as JSON, or null on 404.
|
||||
*******************************************************************************/
|
||||
public JSONObject getApiJsonOrNull(String pathAndQuery) throws QException
|
||||
{
|
||||
return (getJsonOrNull(runtime().getConfig().getApiRoot() + pathAndQuery));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET JSON, or null on 404.
|
||||
*******************************************************************************/
|
||||
@@ -725,7 +1175,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
*******************************************************************************/
|
||||
static QException mapError(HttpRequestBase request, String path, int status, String body, String token)
|
||||
{
|
||||
String detail = AuthentikRedactor.redactText(errorDetail(body), token);
|
||||
String detail = AuthentikRedactor.redactText(errorDetail(AuthentikRedactor.redactText(body, token)), token);
|
||||
if(AuthentikRedactor.isSensitivePath(path))
|
||||
{
|
||||
detail = "(details omitted for a sensitive endpoint)";
|
||||
@@ -851,7 +1301,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error logging an outbound Authentik call", logPair("path", path));
|
||||
LOG.warn("Error logging an outbound Authentik call", logPair("path", path), logPair("error", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,7 +1318,13 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
return;
|
||||
}
|
||||
String expected = runtime().getConfig().getExpectedServerVersion();
|
||||
fetchServerVersion().ifPresent(actual ->
|
||||
Optional<String> version = fetchServerVersion();
|
||||
if(version.isEmpty())
|
||||
{
|
||||
VERSION_CHECKED.set(false);
|
||||
return;
|
||||
}
|
||||
version.ifPresent(actual ->
|
||||
{
|
||||
if(StringUtils.hasContent(expected) && !actual.startsWith(expected + ".") && !actual.equals(expected))
|
||||
{
|
||||
|
||||
@@ -58,6 +58,17 @@ public final class AuthentikApiSurface
|
||||
{
|
||||
List<Operation> rs = new ArrayList<>();
|
||||
rs.add(new Operation("get", "/admin/version/", Set.of()));
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// virtual membership table, group graph, drift report //
|
||||
///////////////////////////////////////////////////////////
|
||||
rs.add(new Operation("get", "/core/groups/", Set.of("include_users", "include_parents", "include_children", "name", "ordering", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/groups/{key}/", Set.of("include_users")));
|
||||
rs.add(new Operation("get", "/core/users/", Set.of("username", "include_groups", "attributes", "type", "path_startswith", "groups_by_name", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/users/{key}/", Set.of()));
|
||||
rs.add(new Operation("get", "/managed/blueprints/", Set.of("page", "page_size")));
|
||||
rs.add(new Operation("get", "/core/tokens/", Set.of("user__username", "intent", "page", "page_size")));
|
||||
rs.add(new Operation("get", "/events/events/", Set.of("username", "ordering", "page", "page_size")));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
@@ -71,6 +82,10 @@ public final class AuthentikApiSurface
|
||||
List<Operation> rs = new ArrayList<>();
|
||||
for(AuthentikTableSpec spec : AuthentikTables.all().values())
|
||||
{
|
||||
if(spec.isVirtual())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Set<String> listParams = new LinkedHashSet<>(List.of("page", "page_size", "ordering"));
|
||||
for(AuthentikFieldSpec field : spec.getFields())
|
||||
{
|
||||
|
||||
@@ -117,10 +117,14 @@ public class AuthentikFilterMapper
|
||||
boolean usedSearch = false;
|
||||
for(QFilterCriteria criterion : criteria)
|
||||
{
|
||||
if(criterion == null || criterion.getFieldName() == null)
|
||||
if(criterion == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(criterion.getFieldName() == null)
|
||||
{
|
||||
throw (new QUserFacingException("A filter criterion without a field is not supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
|
||||
if(criterion.getOtherFieldName() != null || criterion.getFieldFunction() != null)
|
||||
{
|
||||
@@ -148,7 +152,7 @@ public class AuthentikFilterMapper
|
||||
throw (new QUserFacingException("Only one text-search criterion is supported for " + spec.getLabel() + "."));
|
||||
}
|
||||
usedSearch = true;
|
||||
add(params, usedParams, "search", singleString(field, operator, values));
|
||||
add(params, usedParams, "search", plainSearchTerm(field, singleString(field, operator, values)));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -241,6 +245,13 @@ public class AuthentikFilterMapper
|
||||
for(QFilterOrderBy orderBy : CollectionUtils.nonNullList(orderBys))
|
||||
{
|
||||
AuthentikFieldSpec field = spec.getField(orderBy.getFieldName());
|
||||
if(field != null && field.getOrderingName() == null && spec.getKeyFieldName().equals(field.getName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// UIs often sort by the key by default: use the table's default //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
if(field == null || field.getOrderingName() == null)
|
||||
{
|
||||
throw (new QUserFacingException(spec.getLabel() + " cannot be sorted by " + (field == null ? orderBy.getFieldName() : field.getLabel()) + "."));
|
||||
@@ -339,6 +350,22 @@ public class AuthentikFilterMapper
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik's search parameter also accepts a query language (QLSearch),
|
||||
** which could reach fields the session may not filter on (attributes).
|
||||
** Only plain text is passed: QL operators, quotes and keywords are refused.
|
||||
*******************************************************************************/
|
||||
static String plainSearchTerm(AuthentikFieldSpec field, String term) throws QUserFacingException
|
||||
{
|
||||
if(term.matches(".*[=~\"'()<>!\\\\].*") || term.matches("(?i).*\\s(and|or|not|in)\\s.*"))
|
||||
{
|
||||
throw (new QUserFacingException("Search " + field.getLabel() + " with plain text only (no operators or quotes)."));
|
||||
}
|
||||
return (term);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate filter expressions (e.g. "now minus 90 days") into plain values.
|
||||
*******************************************************************************/
|
||||
@@ -373,7 +400,15 @@ public class AuthentikFilterMapper
|
||||
if(field.getType() == QFieldType.BOOLEAN)
|
||||
{
|
||||
String text = String.valueOf(value).trim().toLowerCase();
|
||||
return (text.equals("true") || text.equals("1") || text.equals("yes") ? "true" : "false");
|
||||
if(text.equals("true") || text.equals("1") || text.equals("yes"))
|
||||
{
|
||||
return ("true");
|
||||
}
|
||||
if(text.equals("false") || text.equals("0") || text.equals("no"))
|
||||
{
|
||||
return ("false");
|
||||
}
|
||||
throw (new QUserFacingException("Not a valid yes/no value for " + field.getLabel() + ": " + value));
|
||||
}
|
||||
return (String.valueOf(value));
|
||||
}
|
||||
|
||||
@@ -101,6 +101,51 @@ public final class AuthentikJson
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value at a nested path (e.g. "user", "username"), as a string, or null.
|
||||
** Objects and arrays are rendered as JSON text.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> at(String... path)
|
||||
{
|
||||
return (o ->
|
||||
{
|
||||
Object current = o;
|
||||
for(String key : path)
|
||||
{
|
||||
if(!(current instanceof JSONObject object) || !object.has(key) || object.isNull(key))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
current = object.get(key);
|
||||
}
|
||||
return (String.valueOf(current));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An integer at a nested path, or null.
|
||||
*******************************************************************************/
|
||||
public static Function<JSONObject, Serializable> intAt(String... path)
|
||||
{
|
||||
Function<JSONObject, Serializable> text = at(path);
|
||||
return (o ->
|
||||
{
|
||||
Serializable value = text.apply(o);
|
||||
try
|
||||
{
|
||||
return (value == null ? null : Integer.valueOf(String.valueOf(value)));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Length of an array value, or null.
|
||||
*******************************************************************************/
|
||||
@@ -219,6 +264,38 @@ public final class AuthentikJson
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entries of a list attribute that stringList() drops: a value that is not a
|
||||
** string or array, or non-string / blank array elements. Empty when fine.
|
||||
*******************************************************************************/
|
||||
public static List<String> malformedEntries(JSONObject object, String key)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
if(object == null || !object.has(key) || object.isNull(key))
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
Object value = object.get(key);
|
||||
if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object element = array.get(i);
|
||||
if(!(element instanceof String s) || s.isBlank())
|
||||
{
|
||||
rs.add(String.valueOf(element));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(!(value instanceof String))
|
||||
{
|
||||
rs.add(String.valueOf(value));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse an Authentik timestamp (ISO-8601 with offset or Z).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -45,6 +45,7 @@ public class AuthentikTableSpec
|
||||
private String defaultOrdering;
|
||||
private List<String> recordLabelFields = new ArrayList<>();
|
||||
private String requiredFilterField;
|
||||
private boolean virtual;
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +147,29 @@ public class AuthentikTableSpec
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark as virtual: served by custom logic in AuthentikActionUtil rather than
|
||||
** by one list endpoint (its filter "params" are field names, not Authentik
|
||||
** query parameters).
|
||||
*******************************************************************************/
|
||||
public AuthentikTableSpec withVirtual()
|
||||
{
|
||||
this.virtual = true;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for virtual
|
||||
*******************************************************************************/
|
||||
public boolean isVirtual()
|
||||
{
|
||||
return (virtual);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a field spec, or null.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -20,10 +20,13 @@ package com.kingsrook.qbits.authentik.client;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.at;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.attributeList;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.bool;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.instant;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.intAt;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.integer;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.joined;
|
||||
import static com.kingsrook.qbits.authentik.client.AuthentikJson.json;
|
||||
@@ -41,8 +44,11 @@ import static com.kingsrook.qbits.authentik.client.AuthentikJson.string;
|
||||
*******************************************************************************/
|
||||
public final class AuthentikTables
|
||||
{
|
||||
public static final String USER = "user";
|
||||
public static final String GROUP = "group";
|
||||
public static final String USER = "user";
|
||||
public static final String GROUP = "group";
|
||||
public static final String GROUP_MEMBER = "groupMember";
|
||||
public static final String SESSION = "session";
|
||||
public static final String EVENT = "event";
|
||||
|
||||
/** User types the qbit shows by default: everything but outposts' internal service accounts. */
|
||||
public static final String[] DEFAULT_USER_TYPES = { "internal", "external", "service_account" };
|
||||
@@ -53,6 +59,9 @@ public final class AuthentikTables
|
||||
{
|
||||
register(users());
|
||||
register(groups());
|
||||
register(groupMembers());
|
||||
register(sessions());
|
||||
register(events());
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +149,7 @@ public final class AuthentikTables
|
||||
spec.withField(new AuthentikFieldSpec("uuid", "UUID", QFieldType.STRING, string("uuid"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "uuid").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("qqqAttributes", "QQQ Attributes", QFieldType.TEXT, qqqAttributes()));
|
||||
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions", QFieldType.TEXT, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("passwordChangeDate", "Password Changed", QFieldType.DATE_TIME, instant("password_change_date"))
|
||||
.withSensitive());
|
||||
spec.withField(new AuthentikFieldSpec("attributes", "Attributes", QFieldType.TEXT, json("attributes"))
|
||||
@@ -176,6 +186,7 @@ public final class AuthentikTables
|
||||
spec.withField(new AuthentikFieldSpec("memberUsername", "Member Username", QFieldType.STRING, o -> null)
|
||||
.withFilter(QCriteriaOperator.EQUALS, "members_by_username").withFilter(QCriteriaOperator.IN, "members_by_username").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("permissions", "QQQ Permissions", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_PERMISSIONS)));
|
||||
spec.withField(new AuthentikFieldSpec("effectivePermissions", "Effective QQQ Permissions (incl. parents)", QFieldType.TEXT, o -> null));
|
||||
spec.withField(new AuthentikFieldSpec("appAccess", "App Access", QFieldType.TEXT, attributeList(AuthentikJson.ATTR_APP_ACCESS)));
|
||||
spec.withField(new AuthentikFieldSpec("roleNames", "Roles", QFieldType.STRING, joined("roles_obj", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("numPk", "Numeric Id", QFieldType.INTEGER, integer("num_pk")).withHidden());
|
||||
@@ -184,4 +195,96 @@ public final class AuthentikTables
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual membership table (group x user). Served by AuthentikActionUtil
|
||||
** from GET /core/groups/{pk}/?include_users=true, GET /core/users/{pk}/
|
||||
** (groups_obj) or a group sweep; rows are synthetic objects keyed by these
|
||||
** field names. Key: "groupPk:userPk".
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec groupMembers()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(GROUP_MEMBER, "Group Memberships", "group_add", "/core/groups/", "id")
|
||||
.withVirtual()
|
||||
.withDefaultOrdering("groupName")
|
||||
.withRecordLabelFields("groupName", "username");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("id", "Id", QFieldType.STRING, string("id")));
|
||||
spec.withField(new AuthentikFieldSpec("groupName", "Group", QFieldType.STRING, string("groupName"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "groupName").withOrdering("groupName"));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, string("username"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "username").withOrdering("username"));
|
||||
spec.withField(new AuthentikFieldSpec("name", "Name", QFieldType.STRING, string("name")));
|
||||
spec.withField(new AuthentikFieldSpec("email", "Email", QFieldType.STRING, string("email")));
|
||||
spec.withField(new AuthentikFieldSpec("isActive", "Active", QFieldType.BOOLEAN, bool("isActive")));
|
||||
spec.withField(new AuthentikFieldSpec("groupPk", "Group Id", QFieldType.STRING, string("groupPk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "groupPk").withFilter(QCriteriaOperator.IN, "groupPk"));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("userPk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "userPk"));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /core/authenticated_sessions/ (uuid). IPs and user agents need audit.read.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec sessions()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(SESSION, "Sessions", "devices", "/core/authenticated_sessions/", "uuid")
|
||||
.withReadPermission(IdentityPermission.AUDIT_READ)
|
||||
.withRecordLabelFields("uuid");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("uuid", "Id", QFieldType.STRING, string("uuid")));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, integer("user")));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, o -> null)
|
||||
.withFilter(QCriteriaOperator.EQUALS, "user__username").withHidden());
|
||||
spec.withField(new AuthentikFieldSpec("lastIp", "Last IP", QFieldType.STRING, string("last_ip"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "session__last_ip"));
|
||||
spec.withField(new AuthentikFieldSpec("lastUserAgent", "Last User Agent", QFieldType.TEXT, string("last_user_agent")));
|
||||
spec.withField(new AuthentikFieldSpec("lastUsed", "Last Used", QFieldType.DATE_TIME, instant("last_used")));
|
||||
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
|
||||
spec.withField(new AuthentikFieldSpec("current", "Current", QFieldType.BOOLEAN, bool("current")));
|
||||
spec.withField(new AuthentikFieldSpec("geoIp", "Geo IP", QFieldType.TEXT, json("geo_ip")));
|
||||
spec.withField(new AuthentikFieldSpec("asn", "ASN", QFieldType.TEXT, json("asn")));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** /events/events/ (uuid). Event contexts and client IPs need audit.read.
|
||||
*******************************************************************************/
|
||||
static AuthentikTableSpec events()
|
||||
{
|
||||
AuthentikTableSpec spec = new AuthentikTableSpec(EVENT, "Events", "receipt_long", "/events/events/", "pk")
|
||||
.withReadPermission(IdentityPermission.AUDIT_READ)
|
||||
.withDefaultOrdering("-created")
|
||||
.withRecordLabelFields("action", "created");
|
||||
|
||||
spec.withField(new AuthentikFieldSpec("pk", "Id", QFieldType.STRING, string("pk")));
|
||||
spec.withField(new AuthentikFieldSpec("created", "Created", QFieldType.DATE_TIME, instant("created")).withOrdering("created"));
|
||||
spec.withField(new AuthentikFieldSpec("action", "Action", QFieldType.STRING, string("action"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "action").withFilter(QCriteriaOperator.IN, "actions"));
|
||||
spec.withField(new AuthentikFieldSpec("username", "Username", QFieldType.STRING, at("user", "username"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "username"));
|
||||
spec.withField(new AuthentikFieldSpec("userPk", "User Id", QFieldType.INTEGER, intAt("user", "pk")));
|
||||
spec.withField(new AuthentikFieldSpec("app", "App", QFieldType.STRING, string("app")));
|
||||
spec.withField(new AuthentikFieldSpec("clientIp", "Client IP", QFieldType.STRING, string("client_ip"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "client_ip"));
|
||||
spec.withField(new AuthentikFieldSpec("modelApp", "Model App", QFieldType.STRING, at("context", "model", "app"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_app"));
|
||||
spec.withField(new AuthentikFieldSpec("modelName", "Model", QFieldType.STRING, at("context", "model", "model_name"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_name"));
|
||||
spec.withField(new AuthentikFieldSpec("modelPk", "Model Id", QFieldType.STRING, at("context", "model", "pk"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_model_pk"));
|
||||
spec.withField(new AuthentikFieldSpec("modelLabel", "Object", QFieldType.STRING, at("context", "model", "name")));
|
||||
spec.withField(new AuthentikFieldSpec("authorizedApp", "Authorized App", QFieldType.STRING, at("context", "authorized_application", "slug"))
|
||||
.withFilter(QCriteriaOperator.EQUALS, "context_authorized_app"));
|
||||
spec.withField(new AuthentikFieldSpec("context", "Context", QFieldType.TEXT, json("context")));
|
||||
spec.withField(new AuthentikFieldSpec("expires", "Expires", QFieldType.DATE_TIME, instant("expires")));
|
||||
return (spec);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.permissions.EffectivePermissionResolver;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The identity drift report (DESIGN.md sections 4.5, 5.1 and 8.2): checks
|
||||
** that Authentik is in the shape the qbit relies on, using only the service
|
||||
** account's read permissions. Each check is independent; a check that cannot
|
||||
** run reports why and the rest still run.
|
||||
**
|
||||
** Checks:
|
||||
** 1. version: server major.minor = expectedServerVersion
|
||||
** 2. blueprints: every blueprint instance applied successfully
|
||||
** 3. service account: exists, is a service account, has no groups, is not a
|
||||
** superuser, is protected; exactly one API token (it can mint its own)
|
||||
** 4. break-glass: at least one active, protected superuser exists
|
||||
** 5. identity admins: at least one active, unprotected human holds both
|
||||
** identity.memberships.manage and identity.permissions.manage (last-admin)
|
||||
** 6. group attributes: qqq.io/permissions entries that grant nothing known;
|
||||
** qqq.io/app-access entries outside applicationsInScope; superuser groups
|
||||
** (or their descendants) carrying qqq.io/permissions
|
||||
** 7. service-account activity: events by the service account touching a
|
||||
** superuser or protected user, or a superuser group (lockout detection)
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReport
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
|
||||
|
||||
private final AuthentikActionUtil client;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AuthentikDriftReport(AuthentikActionUtil client, AuthentikQBitConfig config)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.guard = new AuthentikGuard(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run every check; findings sorted by severity (errors first).
|
||||
*******************************************************************************/
|
||||
public List<DriftFinding> run()
|
||||
{
|
||||
List<DriftFinding> findings = new ArrayList<>();
|
||||
runCheck(findings, "version", this::checkVersion);
|
||||
runCheck(findings, "blueprints", this::checkBlueprints);
|
||||
runCheck(findings, "service account", this::checkServiceAccount);
|
||||
runCheck(findings, "break-glass", this::checkBreakGlass);
|
||||
runCheck(findings, "identity admins", this::checkIdentityAdmins);
|
||||
runCheck(findings, "group attributes", this::checkGroupAttributes);
|
||||
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
|
||||
findings.sort(Comparator.comparing(DriftFinding::severity));
|
||||
return (findings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A check.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Check
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run(List<DriftFinding> findings) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void runCheck(List<DriftFinding> findings, String name, Check check)
|
||||
{
|
||||
try
|
||||
{
|
||||
check.run(findings);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
LOG.warn("Drift check could not run", logPair("check", name), logPair("error", AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
|
||||
findings.add(DriftFinding.error(name, "-", "Check could not run, so its result is UNKNOWN: " + AuthentikRedactor.redactText(message, config.getResolvedApiToken())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 1
|
||||
*******************************************************************************/
|
||||
void checkVersion(List<DriftFinding> findings)
|
||||
{
|
||||
String expected = config.getExpectedServerVersion();
|
||||
String actual = client.fetchServerVersion().orElse(null);
|
||||
if(expected == null || expected.isBlank())
|
||||
{
|
||||
findings.add(DriftFinding.info("version", actual == null ? "Authentik" : "Authentik " + actual, "No expectedServerVersion configured."));
|
||||
}
|
||||
else if(actual == null)
|
||||
{
|
||||
findings.add(DriftFinding.warning("version", "Authentik", "Server version unavailable (GET /admin/version/ failed)."));
|
||||
}
|
||||
else if(!(actual.equals(expected) || actual.startsWith(expected + ".")))
|
||||
{
|
||||
findings.add(DriftFinding.warning("version", "Authentik " + actual, "qbit-authentik was tested against " + expected + ". Run its contract tests against " + actual + " before relying on it."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("version", "Authentik " + actual, "Matches the tested version " + expected + "."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 2
|
||||
*******************************************************************************/
|
||||
void checkBlueprints(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
int failing = 0;
|
||||
for(JSONObject blueprint : client.getAllPages("/managed/blueprints/", 1000))
|
||||
{
|
||||
String status = blueprint.optString("status", "unknown");
|
||||
if(!blueprint.optBoolean("enabled", true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!"successful".equals(status))
|
||||
{
|
||||
failing++;
|
||||
findings.add(DriftFinding.error("blueprints", blueprint.optString("name"),
|
||||
"Blueprint status is \"" + status + "\" (last applied " + blueprint.optString("last_applied", "never") + "). A failed blueprint rolls back entirely; check the worker logs."));
|
||||
}
|
||||
}
|
||||
if(failing == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("blueprints", "all", "Every enabled blueprint applied successfully."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 3
|
||||
*******************************************************************************/
|
||||
void checkServiceAccount(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
String username = config.getEffectiveServiceAccountUsername();
|
||||
JSONArray results = client.getApiJson("/core/users/?include_groups=true&username=" + encode(username)).optJSONArray("results");
|
||||
JSONObject user = results == null || results.isEmpty() ? null : results.getJSONObject(0);
|
||||
if(user == null)
|
||||
{
|
||||
findings.add(DriftFinding.error("service account", username, "Not found, or not visible to the API token's user. Is the token minted for this account?"));
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> problems = new ArrayList<>();
|
||||
if(!"service_account".equals(user.optString("type")))
|
||||
{
|
||||
problems.add("type is " + user.optString("type") + ", not service_account");
|
||||
}
|
||||
if(user.optBoolean("is_superuser", false))
|
||||
{
|
||||
problems.add("it is a SUPERUSER: remove it from every superuser group now");
|
||||
}
|
||||
JSONArray groups = user.optJSONArray("groups_obj");
|
||||
if(groups != null && !groups.isEmpty())
|
||||
{
|
||||
problems.add("it is in " + groups.length() + " group(s); it should be in none");
|
||||
}
|
||||
if(!user.optBoolean("is_active", true))
|
||||
{
|
||||
problems.add("it is inactive");
|
||||
}
|
||||
JSONObject attributes = user.optJSONObject("attributes");
|
||||
if(!AuthentikGuard.isMarkedProtected(user))
|
||||
{
|
||||
problems.add("qqq.io/protected is not true");
|
||||
}
|
||||
for(String problem : problems)
|
||||
{
|
||||
findings.add((problem.contains("SUPERUSER") ? DriftFinding.error("service account", username, problem) : DriftFinding.warning("service account", username, problem)));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// tokens: listing without view_token returns the caller's own tokens //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
List<JSONObject> tokens = client.getAllPages("/core/tokens/?intent=api&user__username=" + encode(username), 200);
|
||||
int apiOwned = 0;
|
||||
if(tokens.size() >= 200)
|
||||
{
|
||||
findings.add(DriftFinding.error("service account", username, "200 or more API tokens for the service account; something is minting tokens."));
|
||||
}
|
||||
for(JSONObject token : tokens)
|
||||
{
|
||||
JSONObject owner = token.optJSONObject("user_obj");
|
||||
if(owner != null && username.equals(owner.optString("username")) && "api".equals(token.optString("intent")))
|
||||
{
|
||||
apiOwned++;
|
||||
}
|
||||
}
|
||||
if(apiOwned == 0)
|
||||
{
|
||||
findings.add(DriftFinding.warning("service account", username, "The service account's own API token is not visible, so extra tokens cannot be detected."));
|
||||
}
|
||||
else if(apiOwned > 1)
|
||||
{
|
||||
findings.add(DriftFinding.warning("service account", username, apiOwned + " API tokens exist for the service account; expected 1. It can mint its own tokens, so an extra one may be a leftover rotation or persistence after a compromise. Delete the ones not in Infisical."));
|
||||
}
|
||||
|
||||
if(problems.isEmpty() && apiOwned == 1)
|
||||
{
|
||||
findings.add(DriftFinding.ok("service account", username, "Service account, no groups, not a superuser, protected, one API token."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 4
|
||||
*******************************************************************************/
|
||||
void checkBreakGlass(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
List<JSONObject> protectedUsers = client.getAllPages("/core/users/?attributes=" + java.net.URLEncoder.encode("{\"" + AuthentikJson.ATTR_PROTECTED + "\": true}", java.nio.charset.StandardCharsets.UTF_8), 200);
|
||||
List<String> breakGlass = new ArrayList<>();
|
||||
for(JSONObject user : protectedUsers)
|
||||
{
|
||||
if(user.optBoolean("is_superuser", false) && user.optBoolean("is_active", true) && AuthentikGuard.isMarkedProtected(user))
|
||||
{
|
||||
breakGlass.add(user.optString("username"));
|
||||
}
|
||||
}
|
||||
if(breakGlass.isEmpty())
|
||||
{
|
||||
findings.add(DriftFinding.error("break-glass", "-", "No active superuser marked qqq.io/protected. Without one, a lockout can only be undone from the cluster (ak shell)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("break-glass", String.join(", ", breakGlass), "Active, protected superuser present."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 5
|
||||
*******************************************************************************/
|
||||
void checkIdentityAdmins(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
List<JSONObject> groups = client.getAllPages("/core/groups/?include_users=true&ordering=name", AuthentikActionUtil.MAX_SWEEP_OBJECTS);
|
||||
GroupGraph graph = GroupGraph.fromGroups(groups);
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, null);
|
||||
|
||||
List<IdentityPermission> required = List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE);
|
||||
Set<Integer> humans = new HashSet<>();
|
||||
for(JSONObject human : client.getAllPages("/core/users/?type=internal&type=external", AuthentikActionUtil.MAX_SWEEP_OBJECTS))
|
||||
{
|
||||
humans.add(human.getInt("pk"));
|
||||
}
|
||||
Set<Integer> checked = new HashSet<>();
|
||||
List<String> admins = new ArrayList<>();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
JSONArray users = group.optJSONArray("users_obj");
|
||||
for(int i = 0; users != null && i < users.length(); i++)
|
||||
{
|
||||
JSONObject user = users.getJSONObject(i);
|
||||
if(!checked.add(user.getInt("pk")) || !user.optBoolean("is_active", true) || !humans.contains(user.getInt("pk")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// members are partial users (no is_superuser): derive it from the //
|
||||
// groups, so superusers (who cannot be managed from QQQ) and other //
|
||||
// protected accounts are not counted //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
boolean superuser = graph.groupsOfUser(user.getInt("pk")).stream().anyMatch(graph::isSuperuserInherited);
|
||||
if(superuser || guard.protection(user).isProtected())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(EffectivePermissionResolver.holdsAll(resolver.resolveUser(user.getInt("pk")), required))
|
||||
{
|
||||
admins.add(user.optString("username"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(admins.isEmpty())
|
||||
{
|
||||
String bootstrap = config.getResolvedBootstrapOperators().isEmpty() ? "" : " bootstrapOperators is set, which covers sign-in for now.";
|
||||
findings.add(DriftFinding.error("identity admins", "-", "Nobody can administer identities from QQQ: no active, unprotected human user holds identity.memberships.manage and identity.permissions.manage. Add an administrator to a group carrying identity.* (e.g. identity-admins) in the Authentik UI." + bootstrap));
|
||||
}
|
||||
else if(admins.size() == 1)
|
||||
{
|
||||
findings.add(DriftFinding.info("identity admins", admins.get(0), "Only one identity administrator. Two-person approval of identity.* grants needs a second one (or the Authentik UI)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(DriftFinding.ok("identity admins", String.join(", ", admins), admins.size() + " identity administrators."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 6
|
||||
*******************************************************************************/
|
||||
void checkGroupAttributes(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = client.resolver();
|
||||
Set<String> inScope = new HashSet<>(config.getApplicationsInScope() == null ? List.of() : config.getApplicationsInScope());
|
||||
int issues = 0;
|
||||
|
||||
for(GroupGraph.Node node : graph.nodes())
|
||||
{
|
||||
for(String bad : node.malformed())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Malformed entry ignored (" + bad + "); qqq.io/permissions and qqq.io/app-access must be lists of strings."));
|
||||
}
|
||||
for(String permission : node.permissions())
|
||||
{
|
||||
if(resolver.expand(permission).isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard)."));
|
||||
}
|
||||
}
|
||||
for(String app : node.appAccess())
|
||||
{
|
||||
if(!inScope.isEmpty() && !inScope.contains(app))
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.info("group attributes", node.name(), "qqq.io/app-access lists \"" + app + "\", which is not in applicationsInScope."));
|
||||
}
|
||||
}
|
||||
if(graph.isSuperuserInherited(node.pk()) && !node.permissions().isEmpty())
|
||||
{
|
||||
issues++;
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "Superuser group (or under one) carries qqq.io/permissions. QQQ cannot manage it; keep QQQ grants on ordinary groups."));
|
||||
}
|
||||
}
|
||||
if(issues == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("group attributes", graph.nodes().size() + " groups", "Every qqq.io/* attribute is valid."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 7
|
||||
*******************************************************************************/
|
||||
void checkServiceAccountActivity(List<DriftFinding> findings) throws QException
|
||||
{
|
||||
String username = config.getEffectiveServiceAccountUsername();
|
||||
List<JSONObject> events = client.getAllPages("/events/events/?ordering=-created&username=" + encode(username), 200);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
int flagged = 0;
|
||||
|
||||
for(JSONObject event : events)
|
||||
{
|
||||
JSONObject context = event.optJSONObject("context");
|
||||
JSONObject model = context == null ? null : context.optJSONObject("model");
|
||||
if(model == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String modelName = model.optString("model_name");
|
||||
String pk = model.optString("pk");
|
||||
boolean sensitive = false;
|
||||
if(!"user".equals(modelName) && !"group".equals(modelName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("model_deleted".equals(event.optString("action")))
|
||||
{
|
||||
sensitive = true;
|
||||
}
|
||||
else if("group".equals(modelName))
|
||||
{
|
||||
sensitive = graph.byPk(pk) == null || graph.isSuperuserInherited(pk);
|
||||
}
|
||||
else if(pk.matches("[0-9]+"))
|
||||
{
|
||||
JSONObject user = client.getApiJsonOrNull("/core/users/" + pk + "/");
|
||||
sensitive = user == null || (guard.protection(user).isProtected() && !username.equals(user.optString("username")));
|
||||
}
|
||||
if(sensitive)
|
||||
{
|
||||
flagged++;
|
||||
findings.add(DriftFinding.error("service-account activity", model.optString("name", pk),
|
||||
"The qbit's service account changed a protected or superuser object, deleted one, or changed one that no longer exists (" + event.optString("action") + " at " + event.optString("created") + "). QQQ never does this; investigate as a possible compromise."));
|
||||
}
|
||||
}
|
||||
if(flagged == 0)
|
||||
{
|
||||
findings.add(DriftFinding.ok("service-account activity", username, "No changes to protected or superuser objects in the last " + events.size() + " service-account events."));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the drift report.
|
||||
**
|
||||
** @param severity ERROR (identity management is broken or unsafe), WARNING
|
||||
** (needs attention), INFO (worth knowing), OK (a check passed)
|
||||
** @param check the check's short name, e.g. "blueprints"
|
||||
** @param subject what the finding is about, e.g. a group name
|
||||
** @param message plain-language explanation and what to do
|
||||
*******************************************************************************/
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Severity, most severe first.
|
||||
*******************************************************************************/
|
||||
public enum Severity
|
||||
{
|
||||
ERROR, WARNING, INFO, OK
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding error(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.ERROR, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding warning(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.WARNING, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding info(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.INFO, check, subject, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static DriftFinding ok(String check, String subject, String message)
|
||||
{
|
||||
return (new DriftFinding(Severity.OK, check, subject, message));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -67,6 +67,12 @@ public class AuthentikAppMetaDataProducer extends QBitComponentMetaDataProducer<
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
|
||||
String driftReport = config.applyPrefix(AuthentikDriftReportWidgetProducer.NAME);
|
||||
if(produced.get(com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData.class, driftReport) != null)
|
||||
{
|
||||
app.withWidgets(java.util.List.of(driftReport));
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Identity Drift Report" widget: a standard TABLE widget (generic UI, no
|
||||
** frontend work) listing the AuthentikDriftReport findings. Needs
|
||||
** identity.admin. The renderer finds the qbit through the "backendName"
|
||||
** default value.
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, AuthentikQBitConfig>
|
||||
{
|
||||
public static final String NAME = "driftReport";
|
||||
public static final String BACKEND_NAME_KEY = "backendName";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(NAME))
|
||||
.withLabel("Identity Drift Report")
|
||||
.withType(WidgetType.TABLE.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withCodeReference(new QCodeReference(AuthentikDriftReportWidgetRenderer.class))
|
||||
.withPermissionRules(IdentityPermission.ADMIN.rules())
|
||||
.withDefaultValue(BACKEND_NAME_KEY, config.getApiBackendName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** After the tables, before the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (500);
|
||||
}
|
||||
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.security.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the drift report as a TableData. Re-checks identity.admin itself
|
||||
** (QQQ core does not enforce permissions for non-HTTP callers, qqq#1061).
|
||||
*******************************************************************************/
|
||||
public class AuthentikDriftReportWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
if(!AuthentikGuard.has(QContext.getQSession(), IdentityPermission.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("You do not have permission to view the identity drift report."));
|
||||
}
|
||||
|
||||
String backendName = String.valueOf(((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues().get(AuthentikDriftReportWidgetProducer.BACKEND_NAME_KEY));
|
||||
AuthentikRuntime runtime = AuthentikRuntime.forBackend(backendName);
|
||||
AuthentikActionUtil client = AuthentikActionUtil.forBackend(backendName);
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(client, runtime.getConfig()).run();
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(DriftFinding finding : findings)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("severity", finding.severity().name());
|
||||
row.put("check", finding.check());
|
||||
row.put("subject", finding.subject());
|
||||
row.put("message", finding.message());
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
List<TableData.Column> columns = List.of(
|
||||
new TableData.Column("default", "Severity", "severity", "10%", "left"),
|
||||
new TableData.Column("default", "Check", "check", "15%", "left"),
|
||||
new TableData.Column("default", "Subject", "subject", "20%", "left"),
|
||||
new TableData.Column("default", "Finding", "message", "55%", "left"));
|
||||
|
||||
TableData tableData = new TableData("Identity Drift Report", columns, rows)
|
||||
.withRowsPerPage(25)
|
||||
.withNoRowsFoundHTML("No findings.");
|
||||
return (new RenderWidgetOutput(tableData));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** authentikEvent: /events/events/ (needs identity.audit.read).
|
||||
*******************************************************************************/
|
||||
public class AuthentikEventTableProducer extends AbstractAuthentikTableProducer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getSpecName()
|
||||
{
|
||||
return (AuthentikTables.EVENT);
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikRedactor;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign-in permissions from Authentik groups (DESIGN.md section 5.6). Use as
|
||||
** (or extend in) the host's OAuth2 authentication customizer.
|
||||
**
|
||||
** 1. Group names come from the token's "groups" claim (kof22's profile
|
||||
** mapping emits direct groups only).
|
||||
** 2. Each group is resolved with its ancestors through the cached group
|
||||
** graph; the union of qqq.io/permissions becomes session permissions,
|
||||
** expanded and checked against the instance's permission catalog.
|
||||
** 3. Static fallbacks (withGroupPermissions) and bootstrapOperators are added.
|
||||
** 4. If Authentik cannot be read, only the static fallbacks (and bootstrap
|
||||
** operators) apply: it never grants everything.
|
||||
**
|
||||
** The host keeps its own user/full-name handling; this only adds permissions.
|
||||
** QQQ instantiates customizers with no arguments, so the API backend is found
|
||||
** by name (default "authentikApi"; override getApiBackendName for a prefix).
|
||||
*******************************************************************************/
|
||||
public class AuthentikGroupPermissionMapper implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikGroupPermissionMapper.class);
|
||||
|
||||
public static final String CLAIMS_CONTEXT_KEY = "jwtPayloadJsonObject";
|
||||
|
||||
private final Map<String, List<String>> staticGroupPermissions = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's API backend name (default: the unprefixed-namespace default).
|
||||
*******************************************************************************/
|
||||
protected String getApiBackendName()
|
||||
{
|
||||
return ("authentikApi");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static fallback: always grant these permissions to members of a group
|
||||
** (useful in tests and dev, and as the floor when Authentik is down).
|
||||
*******************************************************************************/
|
||||
public AuthentikGroupPermissionMapper withGroupPermissions(String groupName, List<String> permissions)
|
||||
{
|
||||
staticGroupPermissions.put(groupName, new ArrayList<>(permissions));
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add permissions from the session's token claims.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
JSONObject claims = context == null ? null : asClaims(context.get(CLAIMS_CONTEXT_KEY));
|
||||
if(claims == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<String> groups = groupNames(claims);
|
||||
String email = claims.optString("email", "").trim().toLowerCase();
|
||||
|
||||
Set<String> permissions = new TreeSet<>(resolve(qInstance, groups, email));
|
||||
if(qSession.getPermissions() != null)
|
||||
{
|
||||
permissions.addAll(qSession.getPermissions());
|
||||
}
|
||||
qSession.setPermissions(permissions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permissions for a user's group names and email.
|
||||
*******************************************************************************/
|
||||
public Set<String> resolve(QInstance qInstance, Collection<String> groupNames, String email)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
for(String group : groupNames)
|
||||
{
|
||||
rs.addAll(staticGroupPermissions.getOrDefault(group, List.of()));
|
||||
}
|
||||
|
||||
AuthentikRuntime runtime = runtime();
|
||||
if(runtime != null && email != null && runtime.getConfig().getResolvedBootstrapOperators().contains(email))
|
||||
{
|
||||
LOG.warn("bootstrapOperators grants all identity permissions; remove it once an identity administrator is set up in Authentik", logPair("email", email));
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
rs.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AuthentikActionUtil client = client(qInstance);
|
||||
GroupGraph graph = client.groupGraph();
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph, runtime == null ? null : runtime.getPermissionCatalog());
|
||||
List<String> pks = new ArrayList<>();
|
||||
for(String name : groupNames)
|
||||
{
|
||||
GroupGraph.Node node = graph.byName(name);
|
||||
if(node != null)
|
||||
{
|
||||
pks.add(node.pk());
|
||||
}
|
||||
}
|
||||
EffectivePermissionResolver.Resolution resolution = resolver.resolveGroups(pks);
|
||||
if(!resolution.unknown().isEmpty())
|
||||
{
|
||||
LOG.info("Group attributes name permissions this app does not have", logPair("unknown", resolution.unknown()));
|
||||
}
|
||||
rs.addAll(resolution.sessionPermissions());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Authentik groups could not be read at sign-in; granting only the static fallbacks", logPair("error", AuthentikRedactor.redactText(e.getMessage(), runtime == null ? null : runtime.getConfig().getResolvedApiToken())));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AuthentikRuntime runtime()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (AuthentikRuntime.forBackend(getApiBackendName()));
|
||||
}
|
||||
catch(IllegalStateException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client bound to the qbit backend of the given instance.
|
||||
*******************************************************************************/
|
||||
protected AuthentikActionUtil client(QInstance qInstance)
|
||||
{
|
||||
if(qInstance == null || !(qInstance.getBackend(getApiBackendName()) instanceof APIBackendMetaData backend))
|
||||
{
|
||||
throw (new IllegalStateException("No Authentik API backend named " + getApiBackendName()));
|
||||
}
|
||||
BaseAPIActionUtil util = QCodeLoader.getAdHoc(BaseAPIActionUtil.class, backend.getActionUtil());
|
||||
util.setBackendMetaData(backend);
|
||||
return ((AuthentikActionUtil) util);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject asClaims(Object value)
|
||||
{
|
||||
if(value instanceof JSONObject object)
|
||||
{
|
||||
return (object);
|
||||
}
|
||||
if(value instanceof Map<?, ?> map)
|
||||
{
|
||||
return (new JSONObject(map));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group names from the "groups" claim (array, or a single string).
|
||||
*******************************************************************************/
|
||||
static List<String> groupNames(JSONObject claims)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
Object groups = claims.opt("groups");
|
||||
if(groups instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
String name = array.optString(i, "").trim();
|
||||
if(!name.isEmpty())
|
||||
{
|
||||
rs.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(groups instanceof String name && !name.isBlank())
|
||||
{
|
||||
rs.add(name.trim());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns groups (and their ancestors) into QQQ permissions (DESIGN.md
|
||||
** section 5.6). Pure: give it a GroupGraph and a permission catalog.
|
||||
**
|
||||
** Rules:
|
||||
** - A group confers the union of qqq.io/permissions on itself and every
|
||||
** ancestor ("inherited parents").
|
||||
** - "identity.*" expands to every identity permission; an identity short
|
||||
** name ("identity.users.read") becomes its session form
|
||||
** ("identity.users.read.hasAccess").
|
||||
** - Any other name ending in ".*" expands against the catalog (prefix match);
|
||||
** with no catalog it is dropped, never granted as a literal wildcard.
|
||||
** - Names not in the catalog are reported as unknown and NOT granted (when a
|
||||
** catalog is given); with no catalog they pass through unchanged.
|
||||
*******************************************************************************/
|
||||
public class EffectivePermissionResolver
|
||||
{
|
||||
private final GroupGraph graph;
|
||||
private final Set<String> catalog;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result for a set of groups.
|
||||
*******************************************************************************/
|
||||
public record Resolution(Set<String> sessionPermissions, Map<String, Set<String>> grantedBy, Set<String> unknown, Set<String> appAccess)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param graph the group hierarchy
|
||||
** @param catalog QQQ permission names that may be granted (session form,
|
||||
** e.g. from PermissionsHelper.getAllAvailablePermissionNames);
|
||||
** null to pass non-identity names through unchecked
|
||||
*******************************************************************************/
|
||||
public EffectivePermissionResolver(GroupGraph graph, Collection<String> catalog)
|
||||
{
|
||||
this.graph = graph;
|
||||
this.catalog = catalog == null ? null : new TreeSet<>(catalog);
|
||||
if(this.catalog != null)
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
this.catalog.add(permission.getSessionPermission());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective permissions of a set of groups (e.g. a user's direct groups).
|
||||
*******************************************************************************/
|
||||
public Resolution resolveGroups(Collection<String> groupPks)
|
||||
{
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
Map<String, Set<String>> grantedBy = new LinkedHashMap<>();
|
||||
Set<String> unknown = new TreeSet<>();
|
||||
Set<String> appAccess = new TreeSet<>();
|
||||
|
||||
Set<String> visited = new LinkedHashSet<>();
|
||||
for(String pk : groupPks)
|
||||
{
|
||||
for(GroupGraph.Node node : graph.selfAndAncestors(pk))
|
||||
{
|
||||
if(!visited.add(node.pk()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
appAccess.addAll(node.appAccess());
|
||||
for(String raw : node.permissions())
|
||||
{
|
||||
Set<String> expanded = expand(raw);
|
||||
if(expanded.isEmpty())
|
||||
{
|
||||
unknown.add(raw);
|
||||
}
|
||||
for(String permission : expanded)
|
||||
{
|
||||
permissions.add(permission);
|
||||
grantedBy.computeIfAbsent(permission, k -> new TreeSet<>()).add(node.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Resolution(permissions, grantedBy, unknown, appAccess));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective permissions of a user from the graph's memberships.
|
||||
*******************************************************************************/
|
||||
public Resolution resolveUser(int userPk)
|
||||
{
|
||||
return (resolveGroups(graph.groupsOfUser(userPk)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expand one attribute entry into session permissions (empty when it
|
||||
** grants nothing known).
|
||||
*******************************************************************************/
|
||||
public Set<String> expand(String raw)
|
||||
{
|
||||
Set<String> rs = new TreeSet<>();
|
||||
if(raw == null || raw.isBlank())
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
String name = raw.trim();
|
||||
|
||||
if(name.equals(IdentityPermission.WILDCARD))
|
||||
{
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
rs.add(permission.getSessionPermission());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(IdentityPermission.fromName(name).isPresent())
|
||||
{
|
||||
rs.add(IdentityPermission.fromName(name).get().getSessionPermission());
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(name.endsWith(".*"))
|
||||
{
|
||||
String prefix = name.substring(0, name.length() - 1);
|
||||
if(catalog != null)
|
||||
{
|
||||
catalog.stream().filter(c -> c.startsWith(prefix)).forEach(rs::add);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
if(catalog == null || catalog.contains(name))
|
||||
{
|
||||
rs.add(name);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a resolution holds every given identity permission.
|
||||
*******************************************************************************/
|
||||
public static boolean holdsAll(Resolution resolution, List<IdentityPermission> required)
|
||||
{
|
||||
return (required.stream().allMatch(p -> resolution.sessionPermissions().contains(p.getSessionPermission())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot of Authentik's group hierarchy: groups by pk with their parents
|
||||
** (many-to-many since 2025.12), own superuser flag, qqq.io/* attributes and
|
||||
** member pks. Ancestry closures are computed on demand and cycle-safe.
|
||||
*******************************************************************************/
|
||||
public class GroupGraph
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** One group.
|
||||
*******************************************************************************/
|
||||
public record Node(String pk, String name, boolean ownSuperuser, List<String> parentPks, List<String> permissions, List<String> appAccess, List<Integer> memberPks, List<String> malformed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final Map<String, Node> byPk = new LinkedHashMap<>();
|
||||
private final Map<String, Node> byName = new LinkedHashMap<>();
|
||||
private final Map<String, Set<String>> ancestors = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build from Authentik group objects (as returned by /core/groups/).
|
||||
*******************************************************************************/
|
||||
public static GroupGraph fromGroups(Collection<JSONObject> groups)
|
||||
{
|
||||
GroupGraph graph = new GroupGraph();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
graph.add(new Node(
|
||||
group.getString("pk"),
|
||||
group.optString("name", ""),
|
||||
group.optBoolean("is_superuser", false),
|
||||
strings(group.optJSONArray("parents")),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_PERMISSIONS)),
|
||||
nonNull(AuthentikJson.stringList(group.optJSONObject("attributes"), AuthentikJson.ATTR_APP_ACCESS)),
|
||||
ints(group.optJSONArray("users")),
|
||||
malformed(group.optJSONObject("attributes"))));
|
||||
}
|
||||
return (graph);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a node.
|
||||
*******************************************************************************/
|
||||
public GroupGraph add(Node node)
|
||||
{
|
||||
byPk.put(node.pk(), node);
|
||||
byName.put(node.name(), node);
|
||||
ancestors.clear();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All groups.
|
||||
*******************************************************************************/
|
||||
public Collection<Node> nodes()
|
||||
{
|
||||
return (byPk.values());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group by pk, or null.
|
||||
*******************************************************************************/
|
||||
public Node byPk(String pk)
|
||||
{
|
||||
return (byPk.get(pk));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group by name, or null.
|
||||
*******************************************************************************/
|
||||
public Node byName(String name)
|
||||
{
|
||||
return (byName.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All ancestors of a group (parents, their parents, ...), not including the
|
||||
** group itself. Unknown parents are ignored; cycles terminate.
|
||||
*******************************************************************************/
|
||||
public Set<String> ancestorPks(String pk)
|
||||
{
|
||||
Set<String> cached = ancestors.get(pk);
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
Node start = byPk.get(pk);
|
||||
if(start != null)
|
||||
{
|
||||
queue.addAll(start.parentPks());
|
||||
}
|
||||
while(!queue.isEmpty())
|
||||
{
|
||||
String next = queue.removeFirst();
|
||||
if(next.equals(pk) || !seen.add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Node parent = byPk.get(next);
|
||||
if(parent != null)
|
||||
{
|
||||
queue.addAll(parent.parentPks());
|
||||
}
|
||||
}
|
||||
Set<String> immutable = java.util.Collections.unmodifiableSet(seen);
|
||||
ancestors.put(pk, immutable);
|
||||
return (immutable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The group and all of its ancestors.
|
||||
*******************************************************************************/
|
||||
public List<Node> selfAndAncestors(String pk)
|
||||
{
|
||||
List<Node> rs = new ArrayList<>();
|
||||
Node self = byPk.get(pk);
|
||||
if(self != null)
|
||||
{
|
||||
rs.add(self);
|
||||
}
|
||||
for(String ancestor : ancestorPks(pk))
|
||||
{
|
||||
Node node = byPk.get(ancestor);
|
||||
if(node != null)
|
||||
{
|
||||
rs.add(node);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a group grants superuser status, itself or through any ancestor
|
||||
** (Authentik's User.all_groups() includes ancestors).
|
||||
*******************************************************************************/
|
||||
public boolean isSuperuserInherited(String pk)
|
||||
{
|
||||
return (selfAndAncestors(pk).stream().anyMatch(Node::ownSuperuser));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** pks of the groups a user is a direct member of.
|
||||
*******************************************************************************/
|
||||
public List<String> groupsOfUser(int userPk)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(Node node : byPk.values())
|
||||
{
|
||||
if(node.memberPks().contains(userPk))
|
||||
{
|
||||
rs.add(node.pk());
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> strings(JSONArray array)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
if(array != null)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object value = array.get(i);
|
||||
rs.add(value instanceof JSONObject object ? object.optString("pk") : String.valueOf(value));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<Integer> ints(JSONArray array)
|
||||
{
|
||||
List<Integer> rs = new ArrayList<>();
|
||||
if(array != null)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
Object value = array.get(i);
|
||||
if(value instanceof Number number)
|
||||
{
|
||||
rs.add(number.intValue());
|
||||
}
|
||||
else if(value instanceof JSONObject object && object.has("pk"))
|
||||
{
|
||||
rs.add(object.getInt("pk"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> malformed(JSONObject attributes)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String key : List.of(AuthentikJson.ATTR_PERMISSIONS, AuthentikJson.ATTR_APP_ACCESS))
|
||||
{
|
||||
for(String bad : AuthentikJson.malformedEntries(attributes, key))
|
||||
{
|
||||
rs.add(key + ": " + bad);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> nonNull(List<String> list)
|
||||
{
|
||||
return (list == null ? List.of() : list);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -176,8 +176,7 @@ public class AuthentikGuard
|
||||
{
|
||||
return (ProtectionResult.of("Authentik superuser"));
|
||||
}
|
||||
JSONObject attributes = user.optJSONObject("attributes");
|
||||
if(attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)))
|
||||
if(isMarkedProtected(user))
|
||||
{
|
||||
return (ProtectionResult.of("marked qqq.io/protected"));
|
||||
}
|
||||
@@ -197,6 +196,17 @@ public class AuthentikGuard
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a user carries qqq.io/protected: true (boolean or "true").
|
||||
*******************************************************************************/
|
||||
public static boolean isMarkedProtected(JSONObject user)
|
||||
{
|
||||
JSONObject attributes = user == null ? null : user.optJSONObject("attributes");
|
||||
return (attributes != null && isTrue(attributes.opt(AuthentikJson.ATTR_PROTECTED)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the session holds an identity permission.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -65,7 +65,10 @@ class AuthentikQBitProducerTest
|
||||
|
||||
QAppMetaData app = qInstance.getApp("authentikAdmin");
|
||||
assertThat(app.getLabel()).isEqualTo("Identity");
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup");
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("authentikUser", "authentikGroup", "authentikGroupMember", "authentikSession", "authentikEvent");
|
||||
assertThat(app.getWidgets()).containsExactly("authentikDriftReport");
|
||||
assertThat(qInstance.getTable("authentikSession").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.audit.read");
|
||||
assertThat(qInstance.getWidget("authentikDriftReport").getPermissionRules().getPermissionBaseName()).isEqualTo("identity.admin");
|
||||
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissionNames(qInstance)).contains(IdentityPermission.USERS_READ.getSessionPermission());
|
||||
assertThat(qInstance.getQBits()).containsKey("com.kingsrook.qbits:authentik");
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikFilterMapper;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.permissions.GroupGraph;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Regression tests for the phase 0/1 review findings.
|
||||
*******************************************************************************/
|
||||
class ReviewFixesTest
|
||||
{
|
||||
private final AuthentikFilterMapper mapper = new AuthentikFilterMapper();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search is plain text only (QLSearch could reach sensitive fields).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void searchIsPlainTextOnly() throws QException
|
||||
{
|
||||
for(String term : List.of("attributes.x = \"y\"", "a ~ b", "(x)", "it's", "a and b", "x OR y", "a\\b"))
|
||||
{
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, term)), true))
|
||||
.as(term).isInstanceOf(QUserFacingException.class).hasMessageContaining("plain text");
|
||||
}
|
||||
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.CONTAINS, "read.probe-1")), false).params().get(0).getValue())
|
||||
.isEqualTo("read.probe-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorting by the key falls back to the default; booleans are strict; a
|
||||
** criterion without a field is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orderingBooleansAndNullFields() throws QException
|
||||
{
|
||||
assertThat(mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter().withOrderBy(new QFilterOrderBy("pk", false)), false).params().get(0).getValue()).isEqualTo("username");
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, "maybe")), false))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
assertThatThrownBy(() -> mapper.map(AuthentikTables.get(AuthentikTables.USER), new QQueryFilter(new QFilterCriteria(null, QCriteriaOperator.EQUALS, "x")), false))
|
||||
.isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Config: https unless loopback or explicitly allowed; groupScope refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configChecks()
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.example.test").validate(new QInstance(), errors);
|
||||
assertThat(errors).anyMatch(e -> e.contains("https"));
|
||||
|
||||
for(String ok : List.of("http://localhost:9000", "http://127.0.0.1:9000"))
|
||||
{
|
||||
List<String> none = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl(ok).validate(new QInstance(), none);
|
||||
assertThat(none).as(ok).isEmpty();
|
||||
}
|
||||
List<String> allowed = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withBaseUrl("http://auth.internal").withAllowInsecureHttp(true).validate(new QInstance(), allowed);
|
||||
assertThat(allowed).isEmpty();
|
||||
|
||||
List<String> scope = new ArrayList<>();
|
||||
AuthentikTestSupport.unitConfig().withGroupScopeNamePrefix("kof").validate(new QInstance(), scope);
|
||||
assertThat(scope).anyMatch(e -> e.contains("groupScopeNamePrefix"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Malformed qqq.io/* attributes are kept as findings, not silently dropped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void malformedAttributes()
|
||||
{
|
||||
GroupGraph graph = GroupGraph.fromGroups(List.of(new JSONObject().put("pk", "g").put("name", "g").put("parents", new JSONArray())
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("cms.edit").put(5).put(new JSONObject())).put("qqq.io/app-access", 12))));
|
||||
assertThat(graph.byPk("g").permissions()).containsExactly("cms.edit");
|
||||
assertThat(graph.byPk("g").malformed()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the action util: limit 0, ".." keys, scope widening refused,
|
||||
** malformed attributes reported by the drift report.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void actionUtilAndDrift() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withUserScopePathStartsWith("users");
|
||||
QInstance qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
FixtureRouter router = new FixtureRouter().install();
|
||||
|
||||
QueryInput zero = new QueryInput(config.applyPrefix("user"));
|
||||
zero.setFilter(new QQueryFilter().withLimit(0));
|
||||
assertThat(new QueryAction().execute(zero).getRecords()).isEmpty();
|
||||
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("group")).withPrimaryKey(".."))).isNull();
|
||||
assertThat(FixtureActionUtil.REQUESTS.stream().noneMatch(r -> r.getURI().getPath().contains(".."))).isTrue();
|
||||
|
||||
QueryInput widen = new QueryInput(config.applyPrefix("user"));
|
||||
widen.setFilter(new QQueryFilter(new QFilterCriteria("type", QCriteriaOperator.EQUALS, "internal_service_account")));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(widen)).hasMessageContaining("outside the users");
|
||||
QueryInput otherPath = new QueryInput(config.applyPrefix("user"));
|
||||
otherPath.setFilter(new QQueryFilter(new QFilterCriteria("path", QCriteriaOperator.STARTS_WITH, "service-accounts")));
|
||||
assertThatThrownBy(() -> new QueryAction().execute(otherPath)).hasMessageContaining("outside the users");
|
||||
|
||||
router.groups.get(0).put("attributes", new JSONObject().put("qqq.io/permissions", 42));
|
||||
router.refreshGraph();
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().startsWith("Malformed entry"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,10 +33,16 @@ import org.apache.http.message.BasicHeader;
|
||||
*******************************************************************************/
|
||||
public class FixtureActionUtil extends AuthentikActionUtil
|
||||
{
|
||||
public static final String EMPTY_PAGE = "{\"pagination\":{\"count\":0,\"current\":1,\"total_pages\":1},\"results\":[]}";
|
||||
|
||||
public static final List<HttpRequestBase> REQUESTS = new ArrayList<>();
|
||||
|
||||
public static Function<HttpRequestBase, QHttpResponse> handler = r -> response(404, "{\"detail\":\"Not found.\"}");
|
||||
|
||||
/** Body served for the group-graph sweep (GET /core/groups/?include_users=false&include_parents=false...). */
|
||||
public static String groupGraphBody = EMPTY_PAGE;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -46,6 +52,7 @@ public class FixtureActionUtil extends AuthentikActionUtil
|
||||
{
|
||||
REQUESTS.clear();
|
||||
handler = r -> response(404, "{\"detail\":\"Not found.\"}");
|
||||
groupGraphBody = EMPTY_PAGE;
|
||||
AuthentikActionUtil.resetVersionCheck();
|
||||
}
|
||||
|
||||
@@ -62,6 +69,10 @@ public class FixtureActionUtil extends AuthentikActionUtil
|
||||
{
|
||||
return (response(200, AuthentikTestSupport.resource("fixtures/2026.8.3/admin-version.json")));
|
||||
}
|
||||
if(request.getURI().getPath().endsWith("/core/groups/") && String.valueOf(request.getURI().getRawQuery()).contains("include_parents=false"))
|
||||
{
|
||||
return (response(200, groupGraphBody));
|
||||
}
|
||||
return (handler.apply(request));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A FixtureActionUtil handler that answers like a small Authentik built from
|
||||
** the recorded 2026.8.3 fixtures: users (with groups), groups (with users),
|
||||
** tokens, blueprints, events and sessions, honouring the query parameters
|
||||
** the qbit sends. Tests mutate the JSON to build scenarios.
|
||||
*******************************************************************************/
|
||||
public class FixtureRouter implements Function<HttpRequestBase, QHttpResponse>
|
||||
{
|
||||
public final List<JSONObject> users = results("users-list");
|
||||
public final List<JSONObject> groups = results("groups-list-with-users");
|
||||
public final List<JSONObject> tokens = results("tokens-list");
|
||||
public final List<JSONObject> blueprints = results("blueprints-list");
|
||||
public final List<JSONObject> events = results("events-by-service-account");
|
||||
public final List<JSONObject> sessions = results("sessions-list");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Results of a recorded list fixture (mutable copies).
|
||||
*******************************************************************************/
|
||||
public static List<JSONObject> results(String fixture)
|
||||
{
|
||||
JSONArray array = new JSONObject(FixtureActionUtil.fixture(fixture)).getJSONArray("results");
|
||||
List<JSONObject> rs = new ArrayList<>();
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
rs.add(new JSONObject(array.getJSONObject(i).toString()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install as the FixtureActionUtil handler (and as the group graph).
|
||||
*******************************************************************************/
|
||||
public FixtureRouter install()
|
||||
{
|
||||
FixtureActionUtil.handler = this;
|
||||
FixtureActionUtil.groupGraphBody = page(groups).toString();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh the group-graph body after mutating groups.
|
||||
*******************************************************************************/
|
||||
public void refreshGraph()
|
||||
{
|
||||
FixtureActionUtil.groupGraphBody = page(groups).toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QHttpResponse apply(HttpRequestBase request)
|
||||
{
|
||||
String path = request.getURI().getPath().replaceFirst("^/api/v3", "");
|
||||
if(path.equals("/core/users/"))
|
||||
{
|
||||
return (ok(page(filter(users, request, "username", "username"))));
|
||||
}
|
||||
if(path.matches("/core/users/[0-9]+/"))
|
||||
{
|
||||
int pk = Integer.parseInt(path.split("/")[3]);
|
||||
return (users.stream().filter(u -> u.getInt("pk") == pk).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
|
||||
}
|
||||
if(path.equals("/core/groups/"))
|
||||
{
|
||||
return (ok(page(filter(groups, request, "name", "name"))));
|
||||
}
|
||||
if(path.matches("/core/groups/[^/]+/"))
|
||||
{
|
||||
String pk = path.split("/")[3];
|
||||
return (groups.stream().filter(g -> g.getString("pk").equals(pk)).findFirst().map(FixtureRouter::ok).orElse(FixtureActionUtil.response(404, "{}")));
|
||||
}
|
||||
if(path.equals("/core/tokens/"))
|
||||
{
|
||||
return (ok(page(tokens)));
|
||||
}
|
||||
if(path.equals("/managed/blueprints/"))
|
||||
{
|
||||
return (ok(page(blueprints)));
|
||||
}
|
||||
if(path.equals("/events/events/"))
|
||||
{
|
||||
return (ok(page(filter(events, request, "username", null))));
|
||||
}
|
||||
if(path.equals("/core/authenticated_sessions/"))
|
||||
{
|
||||
return (ok(page(sessions)));
|
||||
}
|
||||
return (FixtureActionUtil.response(404, "{\"detail\":\"Not found.\"}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply a simple equality query parameter, plus the users attributes filter.
|
||||
*******************************************************************************/
|
||||
private static List<JSONObject> filter(List<JSONObject> objects, HttpRequestBase request, String param, String property)
|
||||
{
|
||||
String value = param(request, param);
|
||||
String attrs = param(request, "attributes");
|
||||
Predicate<JSONObject> keep = o -> true;
|
||||
if(value != null && property != null)
|
||||
{
|
||||
keep = keep.and(o -> value.equals(o.optString(property)));
|
||||
}
|
||||
if(attrs != null)
|
||||
{
|
||||
JSONObject wanted = new JSONObject(attrs);
|
||||
keep = keep.and(o ->
|
||||
{
|
||||
JSONObject attributes = o.optJSONObject("attributes");
|
||||
return (attributes != null && wanted.keySet().stream().allMatch(k -> String.valueOf(wanted.get(k)).equals(String.valueOf(attributes.opt(k)))));
|
||||
});
|
||||
}
|
||||
return (objects.stream().filter(keep).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String param(HttpRequestBase request, String name)
|
||||
{
|
||||
String query = request.getURI().getRawQuery();
|
||||
if(query == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String pair : query.split("&"))
|
||||
{
|
||||
String[] parts = pair.split("=", 2);
|
||||
if(parts[0].equals(name) && parts.length == 2)
|
||||
{
|
||||
return (URLDecoder.decode(parts[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static JSONObject page(List<JSONObject> objects)
|
||||
{
|
||||
return (new JSONObject()
|
||||
.put("pagination", new JSONObject().put("count", objects.size()).put("current", 1).put("total_pages", 1))
|
||||
.put("results", new JSONArray(objects)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QHttpResponse ok(JSONObject body)
|
||||
{
|
||||
return (FixtureActionUtil.response(200, body.toString()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.client;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase 1 tables over the recorded fixtures: the virtual membership table,
|
||||
** sessions, events, and the graph-derived fields on users and groups.
|
||||
*******************************************************************************/
|
||||
class Phase1TablesTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private FixtureRouter router;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
router = new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(config.applyPrefix(table));
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private JSONObject group(String name)
|
||||
{
|
||||
return (router.groups.stream().filter(g -> g.getString("name").equals(name)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memberships: by group name / pk, by user, by key, sweep, ordering, paging.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberships() throws QException
|
||||
{
|
||||
JSONObject child = group("phase1-child-of-identity-admins");
|
||||
JSONObject member = child.getJSONArray("users_obj").getJSONObject(0);
|
||||
int userPk = member.getInt("pk");
|
||||
String groupPk = child.getString("pk");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// the recorded users list predates this user: add it, with its groups //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
router.users.add(new JSONObject(member.toString()).put("type", "internal").put("path", "users")
|
||||
.put("groups_obj", new org.json.JSONArray().put(new JSONObject().put("pk", groupPk).put("name", child.getString("name")))));
|
||||
|
||||
List<QRecord> byName = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
|
||||
assertThat(byName).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
|
||||
assertThat(byName.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
|
||||
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupPk", QCriteriaOperator.IN, List.of(groupPk, "missing"))))).hasSize(1);
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)))).extracting(r -> r.getValueString("groupName")).contains("phase1-child-of-identity-admins");
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member")))).isNotEmpty();
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "nobody")))).isEmpty();
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, 999999)))).isEmpty();
|
||||
|
||||
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
|
||||
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
|
||||
assertThat(new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey("no-colon"))).isNull();
|
||||
|
||||
List<QRecord> all = query("groupMember", new QQueryFilter());
|
||||
int count = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount();
|
||||
assertThat(all).hasSize(count).isNotEmpty();
|
||||
assertThat(all).extracting(r -> r.getValueString("groupName")).isSortedAccordingTo(String.CASE_INSENSITIVE_ORDER);
|
||||
|
||||
List<QRecord> byUserDesc = query("groupMember", new QQueryFilter().withOrderBy(new QFilterOrderBy("username", false)).withLimit(2).withSkip(1));
|
||||
assertThat(byUserDesc).hasSize(2);
|
||||
|
||||
assertThatThrownBy(() -> query("groupMember", new QQueryFilter(new QFilterCriteria("email", QCriteriaOperator.EQUALS, "x")))).isInstanceOf(QUserFacingException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With a user scope, membership rows are checked member by member.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void membershipsHonourUserScope() throws QException
|
||||
{
|
||||
config.withUserScopePathStartsWith("nowhere/");
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Graph-derived fields: effective permissions (inherited) and inherited
|
||||
** superuser.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void derivedFields() throws QException
|
||||
{
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/core/groups/")
|
||||
? FixtureActionUtil.response(200, FixtureRouter.page(router.groups).toString())
|
||||
: router.apply(request);
|
||||
|
||||
List<QRecord> groups = query("group", new QQueryFilter());
|
||||
QRecord child = groups.stream().filter(g -> "phase1-child-of-identity-admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess");
|
||||
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
|
||||
QRecord admins = groups.stream().filter(g -> "authentik Admins".equals(g.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
|
||||
|
||||
JSONObject member = router.users.stream().filter(u -> u.getString("username").equals("read.probe1")).findFirst().orElseThrow();
|
||||
FixtureActionUtil.handler = router;
|
||||
QRecord user = new GetAction().executeForRecord(new GetInput(config.applyPrefix("user")).withPrimaryKey(member.getInt("pk")));
|
||||
assertThat(user.getValue("effectivePermissions")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions and events need identity.audit.read and map recorded rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sessionsAndEvents() throws QException
|
||||
{
|
||||
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/events/events/")
|
||||
? FixtureActionUtil.response(200, FixtureActionUtil.fixture("events-list"))
|
||||
: router.apply(request);
|
||||
|
||||
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
|
||||
assertThat(sessions).isNotEmpty();
|
||||
assertThat(sessions.get(0).getValueString("uuid")).isNotBlank();
|
||||
assertThat(sessions.get(0).getValue("lastUsed")).isInstanceOf(java.time.Instant.class);
|
||||
|
||||
List<QRecord> events = query("event", new QQueryFilter(new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(5));
|
||||
assertThat(events).isNotEmpty().allMatch(e -> "group".equals(e.getValueString("modelName")));
|
||||
assertThat(events.get(0).getValueString("modelApp")).isEqualTo("authentik_core");
|
||||
assertThat(events.get(0).getValue("created")).isInstanceOf(java.time.Instant.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import org.testcontainers.utility.MountableFile;
|
||||
public final class AuthentikTestStack
|
||||
{
|
||||
public static final String BOOTSTRAP_TOKEN = "contract-test-bootstrap-token-0123456789";
|
||||
public static final String BOOTSTRAP_PASSWORD = "contract-test-password-123";
|
||||
public static final String SERVICE_ACCOUNT = "qqq-kof22-admin-idadmin";
|
||||
public static final String BREAK_GLASS = "kof22-breakglass";
|
||||
public static final String SUPERUSER_GROUP = "authentik Admins";
|
||||
@@ -116,7 +117,7 @@ public final class AuthentikTestStack
|
||||
env.put("AUTHENTIK_POSTGRESQL__PASSWORD", "authentik-test");
|
||||
env.put("AUTHENTIK_POSTGRESQL__NAME", "authentik");
|
||||
env.put("AUTHENTIK_BOOTSTRAP_TOKEN", BOOTSTRAP_TOKEN);
|
||||
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", "contract-test-password-123");
|
||||
env.put("AUTHENTIK_BOOTSTRAP_PASSWORD", BOOTSTRAP_PASSWORD);
|
||||
env.put("AUTHENTIK_BOOTSTRAP_EMAIL", "akadmin@example.test");
|
||||
env.put("AUTHENTIK_EMAIL__HOST", "mailpit");
|
||||
env.put("AUTHENTIK_EMAIL__PORT", "1025");
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.net.CookieManager;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signs a user in through Authentik's default authentication flow (flow
|
||||
** executor API), creating a real AuthenticatedSession and login event in the
|
||||
** disposable test instance. Test-only.
|
||||
*******************************************************************************/
|
||||
public final class FlowLogin
|
||||
{
|
||||
private static final String FLOW = "default-authentication-flow";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not instantiable.
|
||||
*******************************************************************************/
|
||||
private FlowLogin()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run identification + password; returns the final challenge component
|
||||
** (e.g. "xak-flow-redirect" on success).
|
||||
*******************************************************************************/
|
||||
public static String login(String baseUrl, String username, String password) throws Exception
|
||||
{
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.cookieHandler(new CookieManager())
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
String executor = baseUrl + "/api/v3/flows/executor/" + FLOW + "/?query=";
|
||||
|
||||
JSONObject challenge = send(client, HttpRequest.newBuilder(URI.create(executor)).GET());
|
||||
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-identification").put("uid_field", username)));
|
||||
if("ak-stage-password".equals(challenge.optString("component")))
|
||||
{
|
||||
challenge = send(client, post(executor, new JSONObject().put("component", "ak-stage-password").put("password", password)));
|
||||
}
|
||||
return (challenge.optString("component"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static HttpRequest.Builder post(String url, JSONObject body)
|
||||
{
|
||||
return (HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject send(HttpClient client, HttpRequest.Builder request) throws Exception
|
||||
{
|
||||
HttpResponse<String> response = client.send(request.header("Accept", "application/json").timeout(Duration.ofSeconds(30)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
return (response.body() == null || response.body().isBlank() || !response.body().trim().startsWith("{") ? new JSONObject() : new JSONObject(response.body()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.contract;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase 1 read MVP against Authentik 2026.8.3, as the service account with
|
||||
** the phase 0/1 role: memberships (virtual table), sessions and events
|
||||
** (identity.audit.read), effective permissions and inherited superuser on
|
||||
** groups, and the drift report. Runs after SuperuserFloorIT, whose lockout
|
||||
** probes are exactly what the drift report must flag. Records fixtures.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Order(70)
|
||||
class Phase1ReadContractIT
|
||||
{
|
||||
private static AuthentikTestStack stack;
|
||||
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A member of a child of identity-admins (inherited identity.*), and a real
|
||||
** session for akadmin.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void beforeAll() throws Exception
|
||||
{
|
||||
stack = AuthentikTestStack.get();
|
||||
TestApi admin = stack.admin();
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "phase1-child-of-identity-admins").put("parents", new JSONArray().put(admin.groupPk("identity-admins"))));
|
||||
admin.post("/core/users/", new JSONObject().put("username", "phase1.member").put("name", "Phase One").put("email", "phase1@example.test").put("type", "internal").put("path", "users"));
|
||||
admin.post("/core/groups/" + admin.groupPk("phase1-child-of-identity-admins") + "/add_user/", new JSONObject().put("pk", admin.userPk("phase1.member")));
|
||||
admin.post("/core/groups/", new JSONObject().put("name", "phase1-bad-attributes").put("attributes",
|
||||
new JSONObject().put("qqq.io/permissions", new JSONArray().put("no.such.permission")).put("qqq.io/app-access", new JSONArray().put("not-in-scope-app"))));
|
||||
|
||||
assertThat(FlowLogin.login(stack.baseUrl(), "akadmin", AuthentikTestStack.BOOTSTRAP_PASSWORD)).isEqualTo("xak-flow-redirect");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
config = new AuthentikQBitConfig()
|
||||
.withBaseUrl(stack.baseUrl())
|
||||
.withApiToken(stack.serviceAccountToken())
|
||||
.withAppKey("kof22-admin")
|
||||
.withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG))
|
||||
.withListCacheTtlSeconds(0)
|
||||
.withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, QbitReadContractIT.RecordingActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(config.applyPrefix(table));
|
||||
input.setFilter(filter);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memberships by group, by user, by key, and a full sweep.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void memberships() throws QException
|
||||
{
|
||||
TestApi admin = stack.admin();
|
||||
String groupPk = admin.groupPk("phase1-child-of-identity-admins");
|
||||
int userPk = admin.userPk("phase1.member");
|
||||
|
||||
List<QRecord> byGroup = query("groupMember", new QQueryFilter(new QFilterCriteria("groupName", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins")));
|
||||
assertThat(byGroup).extracting(r -> r.getValueString("username")).containsExactly("phase1.member");
|
||||
assertThat(byGroup.get(0).getValueString("id")).isEqualTo(groupPk + ":" + userPk);
|
||||
assertThat(byGroup.get(0).getValueString("email")).isEqualTo("phase1@example.test");
|
||||
FixtureRecorder.record("group-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
|
||||
|
||||
List<QRecord> byUser = query("groupMember", new QQueryFilter(new QFilterCriteria("userPk", QCriteriaOperator.EQUALS, userPk)));
|
||||
assertThat(byUser).extracting(r -> r.getValueString("groupName")).containsExactly("phase1-child-of-identity-admins");
|
||||
|
||||
assertThat(query("groupMember", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, AuthentikTestStack.BREAK_GLASS))))
|
||||
.extracting(r -> r.getValueString("groupName")).containsExactly(AuthentikTestStack.SUPERUSER_GROUP);
|
||||
|
||||
QRecord byKey = new GetAction().executeForRecord(new GetInput(config.applyPrefix("groupMember")).withPrimaryKey(groupPk + ":" + userPk));
|
||||
assertThat(byKey.getValueString("username")).isEqualTo("phase1.member");
|
||||
|
||||
int all = new CountAction().execute(new CountInput(config.applyPrefix("groupMember"))).getCount();
|
||||
assertThat(all).isGreaterThanOrEqualTo(3);
|
||||
FixtureRecorder.record("groups-list-with-users", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/groups/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Groups: inherited superuser and effective permissions (incl. parents);
|
||||
** users: effective permissions through a child group.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void effectivePermissionsAndInheritedSuperuser() throws QException
|
||||
{
|
||||
QRecord child = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "phase1-child-of-identity-admins"))).get(0);
|
||||
assertThat(child.getValueString("permissions")).isNull();
|
||||
assertThat(child.getValueString("effectivePermissions")).contains("identity.memberships.manage.hasAccess", "identity.users.read.hasAccess");
|
||||
assertThat(child.getValueBoolean("isSuperuserInherited")).isFalse();
|
||||
|
||||
QRecord admins = query("group", new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, AuthentikTestStack.SUPERUSER_GROUP))).get(0);
|
||||
assertThat(admins.getValueBoolean("isSuperuserInherited")).isTrue();
|
||||
|
||||
QRecord member = query("user", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "phase1.member"))).get(0);
|
||||
assertThat(member.getValueString("effectivePermissions")).contains("identity.permissions.manage.hasAccess");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions and events: audit.read only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sessionsAndEvents() throws QException
|
||||
{
|
||||
assertThatThrownBy(() -> query("session", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> query("event", new QQueryFilter())).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ, IdentityPermission.AUDIT_READ);
|
||||
List<QRecord> sessions = query("session", new QQueryFilter(new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")));
|
||||
assertThat(sessions).isNotEmpty();
|
||||
assertThat(sessions.get(0).getValueString("lastIp")).isNotBlank();
|
||||
FixtureRecorder.record("sessions-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/authenticated_sessions/"));
|
||||
|
||||
List<QRecord> logins = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.EQUALS, "login"), new QFilterCriteria("username", QCriteriaOperator.EQUALS, "akadmin")).withLimit(5));
|
||||
assertThat(logins).isNotEmpty();
|
||||
assertThat(logins.get(0).getValueString("action")).isEqualTo("login");
|
||||
|
||||
List<QRecord> modelEvents = query("event", new QQueryFilter(new QFilterCriteria("action", QCriteriaOperator.IN, List.of("model_created", "model_updated")),
|
||||
new QFilterCriteria("modelName", QCriteriaOperator.EQUALS, "group")).withLimit(10));
|
||||
assertThat(modelEvents).isNotEmpty().allMatch(r -> "group".equals(r.getValueString("modelName")));
|
||||
FixtureRecorder.record("events-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The drift report on the contract instance. Expected: blueprints, service
|
||||
** account and break-glass OK; the bad attributes flagged; and the
|
||||
** service account's lockout probes on break-glass (SuperuserFloorIT)
|
||||
** flagged as an ERROR.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void driftReport() throws QException
|
||||
{
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
List<DriftFinding> findings = new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run();
|
||||
findings.forEach(f -> System.out.println("[drift] " + f));
|
||||
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("version") && f.severity() == DriftFinding.Severity.OK);
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("break-glass") && f.severity() == DriftFinding.Severity.OK && f.subject().contains(AuthentikTestStack.BREAK_GLASS));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.OK);
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("identity admins") && f.subject().contains("phase1.member"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("no.such.permission"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.subject().equals("phase1-bad-attributes") && f.message().contains("not-in-scope-app"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service-account activity") && f.severity() == DriftFinding.Severity.ERROR);
|
||||
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
|
||||
|
||||
FixtureRecorder.record("blueprints-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/managed/blueprints/"));
|
||||
FixtureRecorder.record("tokens-list", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/core/tokens/"));
|
||||
FixtureRecorder.record("events-by-service-account", QbitReadContractIT.RecordingActionUtil.LAST_BODIES.get("GET /api/v3/events/events/"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.drift;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikDriftReportWidgetRenderer;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drift report on the recorded 2026.8.3 contract-test instance, and the
|
||||
** scenarios it must catch.
|
||||
*******************************************************************************/
|
||||
class AuthentikDriftReportTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
private FixtureRouter router;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withApplicationsInScope(List.of("kof22-website-prod")).withGroupCacheTtlSeconds(0);
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.values());
|
||||
router = new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<DriftFinding> run() throws QException
|
||||
{
|
||||
router.refreshGraph();
|
||||
return (new AuthentikDriftReport(AuthentikActionUtil.forBackend(config.getApiBackendName()), config).run());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static DriftFinding find(List<DriftFinding> findings, String check, DriftFinding.Severity severity)
|
||||
{
|
||||
return (findings.stream().filter(f -> f.check().equals(check) && f.severity() == severity).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorded instance, as the contract test saw it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordedInstance() throws QException
|
||||
{
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(find(findings, "version", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "blueprints", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "service account", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(find(findings, "break-glass", DriftFinding.Severity.OK)).isNotNull();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("group attributes") && f.message().contains("no.such.permission"));
|
||||
assertThat(find(findings, "service-account activity", DriftFinding.Severity.ERROR)).as("the lockout probes on break-glass").isNotNull();
|
||||
assertThat(findings.get(0).severity()).isEqualTo(DriftFinding.Severity.ERROR);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsafe states.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsafeStates() throws QException
|
||||
{
|
||||
JSONObject serviceAccount = router.users.stream().filter(u -> u.getString("username").equals("qqq-kof22-admin-idadmin")).findFirst().orElseThrow();
|
||||
serviceAccount.put("is_superuser", true).put("type", "internal").put("is_active", false).put("attributes", new JSONObject())
|
||||
.put("groups_obj", new JSONArray().put(new JSONObject().put("pk", "x").put("name", "authentik Admins")));
|
||||
router.tokens.add(new JSONObject(router.tokens.get(0).toString()).put("identifier", "second"));
|
||||
router.blueprints.get(0).put("status", "error");
|
||||
router.users.forEach(u -> u.put("attributes", new JSONObject()));
|
||||
router.groups.forEach(g -> g.put("users_obj", new JSONArray()).put("users", new JSONArray()));
|
||||
router.events.clear();
|
||||
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.severity() == DriftFinding.Severity.ERROR && f.message().contains("SUPERUSER"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("type is internal"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("inactive"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("qqq.io/protected"));
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("service account") && f.message().contains("2 API tokens"));
|
||||
assertThat(find(findings, "blueprints", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "break-glass", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "identity admins", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "service-account activity", DriftFinding.Severity.OK)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing service account, version mismatch, a check that cannot run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingAndUnreachable() throws QException
|
||||
{
|
||||
router.users.removeIf(u -> u.getString("username").equals("qqq-kof22-admin-idadmin"));
|
||||
config.withExpectedServerVersion("2099.1");
|
||||
router.blueprints.clear();
|
||||
FixtureActionUtil.handler = request -> request.getURI().getPath().endsWith("/managed/blueprints/")
|
||||
? FixtureActionUtil.response(403, "{\"detail\":\"You do not have permission to perform this action.\"}")
|
||||
: router.apply(request);
|
||||
|
||||
List<DriftFinding> findings = run();
|
||||
assertThat(find(findings, "service account", DriftFinding.Severity.ERROR)).isNotNull();
|
||||
assertThat(find(findings, "version", DriftFinding.Severity.WARNING)).isNotNull();
|
||||
assertThat(findings).anyMatch(f -> f.check().equals("blueprints") && f.message().startsWith("Check could not run"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widget renders the findings, and needs identity.admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void widgetRendersAndChecksPermission() throws QException
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput();
|
||||
input.setWidgetMetaData(qInstance.getWidget(config.applyPrefix("driftReport")));
|
||||
TableData table = (TableData) new AuthentikDriftReportWidgetRenderer().render(input).getWidgetData();
|
||||
assertThat(table.getColumns()).hasSize(4);
|
||||
assertThat(table.getRows()).isNotEmpty();
|
||||
assertThat(table.getRows().get(0).get("severity")).isEqualTo("ERROR");
|
||||
|
||||
AuthentikTestSupport.loginWith(qInstance, IdentityPermission.USERS_READ);
|
||||
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikTestSupport;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.FixtureRouter;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for AuthentikGroupPermissionMapper over the recorded group graph.
|
||||
*******************************************************************************/
|
||||
class AuthentikGroupPermissionMapperTest
|
||||
{
|
||||
private QInstance qInstance;
|
||||
private AuthentikQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withGroupCacheTtlSeconds(0).withBootstrapOperators("Boot@Example.Test");
|
||||
qInstance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(qInstance, config, FixtureActionUtil.class);
|
||||
QContext.init(qInstance, new QSession());
|
||||
new FixtureRouter().install();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> context(String email, Object groups)
|
||||
{
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
context.put(AuthentikGroupPermissionMapper.CLAIMS_CONTEXT_KEY, new JSONObject().put("email", email).put("groups", groups));
|
||||
return (context);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inherited identity.* through a child group; existing permissions kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupsBecomePermissions()
|
||||
{
|
||||
QSession session = new QSession();
|
||||
session.setPermissions(Set.of("already.there"));
|
||||
new AuthentikGroupPermissionMapper().customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins").put("no-such-group")));
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(session.getPermissions()).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(session.getPermissions()).contains("already.there");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static fallbacks, bootstrap operators (case-insensitive), single-string
|
||||
** groups claim; no claims means no change.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbacksAndBootstrap()
|
||||
{
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("kof22-admins", List.of("adminEsb.hasAccess"));
|
||||
|
||||
QSession admin = new QSession();
|
||||
mapper.customizeSession(qInstance, admin, context("someone@example.test", "kof22-admins"));
|
||||
assertThat(admin.getPermissions()).contains("adminEsb.hasAccess").doesNotContain(IdentityPermission.USERS_READ.getSessionPermission());
|
||||
|
||||
QSession boot = new QSession();
|
||||
mapper.customizeSession(qInstance, boot, context("boot@example.test", new JSONArray()));
|
||||
assertThat(boot.getPermissions()).contains(IdentityPermission.MEMBERSHIPS_MANAGE.getSessionPermission());
|
||||
|
||||
QSession none = new QSession();
|
||||
mapper.customizeSession(qInstance, none, Map.of());
|
||||
mapper.customizeSession(qInstance, none, null);
|
||||
assertThat(none.getPermissions()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik unreachable: only the static fallbacks, never everything.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void authentikDownGrantsOnlyFallbacks()
|
||||
{
|
||||
FixtureActionUtil.groupGraphBody = "not json";
|
||||
AuthentikGroupPermissionMapper mapper = new AuthentikGroupPermissionMapper().withGroupPermissions("phase1-child-of-identity-admins", List.of("cms.publish"));
|
||||
QSession session = new QSession();
|
||||
mapper.customizeSession(qInstance, session, context("phase1@example.test", new JSONArray().put("phase1-child-of-identity-admins")));
|
||||
assertThat(session.getPermissions()).containsExactly("cms.publish");
|
||||
|
||||
AuthentikGroupPermissionMapper wrongBackend = new AuthentikGroupPermissionMapper()
|
||||
{
|
||||
@Override
|
||||
protected String getApiBackendName()
|
||||
{
|
||||
return ("nope");
|
||||
}
|
||||
};
|
||||
assertThat(wrongBackend.resolve(qInstance, List.of("phase1-child-of-identity-admins"), "x@example.test")).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.authentik.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for GroupGraph and EffectivePermissionResolver.
|
||||
*******************************************************************************/
|
||||
class EffectivePermissionResolverTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** admins (superuser) <- under-admins; identity-admins <- editors <- interns;
|
||||
** publishers (string attribute); a cycle a <-> b.
|
||||
*******************************************************************************/
|
||||
private static GroupGraph graph()
|
||||
{
|
||||
return (GroupGraph.fromGroups(List.of(
|
||||
group("admins", "authentik Admins", true, List.of(), null, null, List.of(1)),
|
||||
group("under", "under-admins", false, List.of("admins"), List.of("cms.publish"), null, List.of(2)),
|
||||
group("ida", "identity-admins", false, List.of(), List.of("identity.*"), List.of("kof22-website-prod"), List.of(3)),
|
||||
group("editors", "editors", false, List.of("ida"), List.of("cms.edit", "cms.*"), null, List.of(4)),
|
||||
group("interns", "interns", false, List.of("editors"), List.of("identity.users.read", "nope.unknown"), null, List.of(5)),
|
||||
new JSONObject().put("pk", "pub").put("name", "publishers").put("is_superuser", false).put("parents", new JSONArray())
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", "cms.publish").put("qqq.io/app-access", "kof22-website-prod")).put("users", new JSONArray().put(6)),
|
||||
group("a", "cycle-a", false, List.of("b"), List.of("cms.edit"), null, List.of()),
|
||||
group("b", "cycle-b", false, List.of("a"), List.of(), null, List.of(7)))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static JSONObject group(String pk, String name, boolean superuser, List<String> parents, List<String> permissions, List<String> appAccess, List<Integer> users)
|
||||
{
|
||||
JSONObject attributes = new JSONObject();
|
||||
if(permissions != null)
|
||||
{
|
||||
attributes.put("qqq.io/permissions", new JSONArray(permissions));
|
||||
}
|
||||
if(appAccess != null)
|
||||
{
|
||||
attributes.put("qqq.io/app-access", new JSONArray(appAccess));
|
||||
}
|
||||
return (new JSONObject().put("pk", pk).put("name", name).put("is_superuser", superuser).put("parents", new JSONArray(parents))
|
||||
.put("attributes", attributes).put("users", new JSONArray(users)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static final Set<String> CATALOG = Set.of("cms.publish", "cms.edit", "cms.delete", "adminEsb.hasAccess");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ancestryAndInheritedSuperuser()
|
||||
{
|
||||
GroupGraph graph = graph();
|
||||
assertThat(graph.ancestorPks("interns")).containsExactly("editors", "ida");
|
||||
assertThat(graph.isSuperuserInherited("under")).isTrue();
|
||||
assertThat(graph.isSuperuserInherited("admins")).isTrue();
|
||||
assertThat(graph.isSuperuserInherited("interns")).isFalse();
|
||||
assertThat(graph.ancestorPks("a")).containsExactly("b");
|
||||
assertThat(graph.ancestorPks("unknown")).isEmpty();
|
||||
assertThat(graph.byName("editors").pk()).isEqualTo("editors");
|
||||
assertThat(graph.groupsOfUser(4)).containsExactly("editors");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inheritance, wildcards, identity names, unknown names, the catalog.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolvesWithInheritanceAndWildcards()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
|
||||
|
||||
EffectivePermissionResolver.Resolution interns = resolver.resolveUser(5);
|
||||
for(IdentityPermission permission : IdentityPermission.values())
|
||||
{
|
||||
assertThat(interns.sessionPermissions()).contains(permission.getSessionPermission());
|
||||
}
|
||||
assertThat(interns.sessionPermissions()).contains("cms.edit", "cms.publish", "cms.delete").doesNotContain("adminEsb.hasAccess", "nope.unknown");
|
||||
assertThat(interns.unknown()).containsExactly("nope.unknown");
|
||||
assertThat(interns.grantedBy().get("cms.delete")).containsExactly("editors");
|
||||
assertThat(interns.grantedBy().get(IdentityPermission.USERS_READ.getSessionPermission())).containsExactlyInAnyOrder("identity-admins", "interns");
|
||||
assertThat(interns.appAccess()).containsExactly("kof22-website-prod");
|
||||
|
||||
assertThat(EffectivePermissionResolver.holdsAll(interns, List.of(IdentityPermission.MEMBERSHIPS_MANAGE, IdentityPermission.PERMISSIONS_MANAGE))).isTrue();
|
||||
assertThat(EffectivePermissionResolver.holdsAll(resolver.resolveUser(6), List.of(IdentityPermission.USERS_READ))).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single string attribute is a list of one; cycles terminate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stringAttributesAndCycles()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), CATALOG);
|
||||
assertThat(resolver.resolveUser(6).sessionPermissions()).containsExactly("cms.publish");
|
||||
assertThat(resolver.resolveUser(6).appAccess()).containsExactly("kof22-website-prod");
|
||||
assertThat(resolver.resolveUser(7).sessionPermissions()).containsExactly("cms.edit");
|
||||
assertThat(resolver.resolveUser(99).sessionPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a catalog: names pass through, but wildcards are never granted
|
||||
** literally.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void withoutCatalog()
|
||||
{
|
||||
EffectivePermissionResolver resolver = new EffectivePermissionResolver(graph(), null);
|
||||
assertThat(resolver.expand("anything.goes")).containsExactly("anything.goes");
|
||||
assertThat(resolver.expand("cms.*")).isEmpty();
|
||||
assertThat(resolver.expand(" identity.audit.read ")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
|
||||
assertThat(resolver.expand("identity.audit.read.hasAccess")).containsExactly(IdentityPermission.AUDIT_READ.getSessionPermission());
|
||||
assertThat(resolver.expand("")).isEmpty();
|
||||
assertThat(resolver.expand(null)).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version_current": "2026.8.3",
|
||||
"outdated": false,
|
||||
"version_latest_valid": false,
|
||||
"version_latest": "0.0.0",
|
||||
"version_latest_valid": true,
|
||||
"version_latest": "2026.8.3",
|
||||
"outpost_outdated": false,
|
||||
"build_hash": ""
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 33,
|
||||
"end_index": 33,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {},
|
||||
"results": [
|
||||
{
|
||||
"path": "default/flow-default-authentication-flow.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Authentication flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "8c42e464620993e314bada38a90279ebfde283c0df1be15610a38f0dda1cc98a8cc16020857c2a58fcfe28c9b40117d29b73fdb197253c012b4382384b3f3c24",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:30.747553Z",
|
||||
"name": "Default - Authentication flow",
|
||||
"context": {},
|
||||
"pk": "9cca0e34-bc1f-4e2c-b0d6-479d5e4fc27b",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/default-brand.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Brand",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "8f15f035ea71a470c13a975bf66d32b031ef4032b0d178a754f43d16a0abf79470929cbb860b0fe884f8f42914310b7beda32db4b34be55e754fa25fd5d7e4b1",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:40.903959Z",
|
||||
"name": "Default - Brand",
|
||||
"context": {},
|
||||
"pk": "b432d6f0-9e72-4a1c-9d28-986b72becda7",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/events-default.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Events Transport & Rules",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "9cf24006d70a07a619a18e8b5707ec58bb306568a228ed8c1ebfe6639400d78df02b9042405e78e6202805ac22180b73e474fb72025652f91a26ab41623ced6d",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:31.539388Z",
|
||||
"name": "Default - Events Transport & Rules",
|
||||
"context": {},
|
||||
"pk": "7e07d275-bde7-42ba-8810-9711d9bcdb0d",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-invalidation-flow.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Invalidation flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "a143bf2b9a1d20e2078389f7c37d213e51c52ea6a8df8f8ae7a7e6a1bb129ec4b862803aa44b8b1dbfe727e98e45cedac7a1a03632065040c87f045dcabab86a",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:32.416250Z",
|
||||
"name": "Default - Invalidation flow",
|
||||
"context": {},
|
||||
"pk": "eb5a6337-3226-4a2d-95a0-031832a06311",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-oobe.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Out-of-box-experience flow",
|
||||
"labels": {
|
||||
"blueprints.goauthentik.io/system-oobe": "true",
|
||||
"blueprints.goauthentik.io/system": "true"
|
||||
}
|
||||
},
|
||||
"last_applied_hash": "38d5b1d1e6b06206bba89bd0859a389ce8324ac1ab095fc9b2a015be5a9e6fbb404bed013f870b94965630726bf183f9c1193ce25aface8211a14e2355a7b791",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:32.140739Z",
|
||||
"name": "Default - Out-of-box-experience flow",
|
||||
"context": {},
|
||||
"pk": "6bc549b0-fa02-4c90-92a8-4496ce65ccff",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-password-change.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Password change flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "4de0acc5fd9e5c5de59cb4bf32db7f192e495ed285454daef8268409d6055b780043a27b895bd0d48262451653fd7299c73f0fdd51dfb4925958a449c6a9f49c",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:32.325034Z",
|
||||
"name": "Default - Password change flow",
|
||||
"context": {},
|
||||
"pk": "ae3a5a27-10d1-4a55-af06-e3cc75e9492f",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-provider-authorization-explicit-consent.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Provider authorization flow (explicit consent)",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "598eee861d8814c0dc200542f59e5d8085c140f7131e87a9fc6baf688a3e9e7b0a32e865c439084f76b11992ce056be574e5aba2664375cee287206d66d345c9",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:30.362956Z",
|
||||
"name": "Default - Provider authorization flow (explicit consent)",
|
||||
"context": {},
|
||||
"pk": "4428307d-f07c-4d18-84f0-b993bdff5219",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-provider-authorization-implicit-consent.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Provider authorization flow (implicit consent)",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "2321088d922a57fdb741f6857eb48d3090210b552a6ecadf9b08de4832f574c5231f37943da3dfda3a4fa07c900d236ace3d237710e2e986a207c758859c1ecb",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:32.232059Z",
|
||||
"name": "Default - Provider authorization flow (implicit consent)",
|
||||
"context": {},
|
||||
"pk": "dfff0561-79fd-47c1-8b0c-aa62578155c5",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-provider-invalidation.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Provider invalidation flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "92310bf9e2aceeec2f8dc4762ce4921588cadd9f5990a23abb796bb84b61a619fc9ddf8e6a9b580a208abc8cabb15cff244238581c089d98a48c15ac5eb078ce",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:27.202867Z",
|
||||
"name": "Default - Provider invalidation flow",
|
||||
"context": {},
|
||||
"pk": "772a73b6-f5bb-48f9-828b-74693208d31e",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/rbac-role-agents.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - RBAC - Agent Users",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "d8340ba349322534e4013ac86f7a662ad4bb82430c13eb9d2fbd1b15a5e2bb306956497c13ec293e117ef8c88625b7325b22e6135f11474a9c49b2dc67bd1659",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:26.831237Z",
|
||||
"name": "Default - RBAC - Agent Users",
|
||||
"context": {},
|
||||
"pk": "1c865e1a-22cb-43f1-bb80-aeda3d135aec",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/rbac-role-read-only.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - RBAC - Read-only",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "2e44f3070adfcfc63abb1429386fd663ee179b5c9db2cfce36e515f8216543cf59d48b9a331db5955c9732024e75068433b3f2b5579b441839522b2c40a8b637",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:31.596000Z",
|
||||
"name": "Default - RBAC - Read-only",
|
||||
"context": {},
|
||||
"pk": "26b9a15d-f2fc-4d05-9d63-177922a123da",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-request-flow.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Request flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "53365439bd7d2ada828846653d4a53556fd1a7ffd7564de5badf9beb1436d3f95bf45bbf02bf6829849f2b861a034f6aac2f53d478201bbfe7aa73cf94744407",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:31.680198Z",
|
||||
"name": "Default - Request flow",
|
||||
"context": {},
|
||||
"pk": "7eccd6fb-7247-4aa6-94e1-7d290e3dacad",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-source-authentication.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Source authentication flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "d86613f21394af35d554692e9868e7e714bb7cbd99a362ef3b64476edc1ffcb832443610f193d3d116685a360a1734482a7152b6b6bc8913f45be9aac3934233",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:29.666916Z",
|
||||
"name": "Default - Source authentication flow",
|
||||
"context": {},
|
||||
"pk": "3e07d4bc-6fb4-4079-a22f-07e5afe43feb",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-source-enrollment.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Source enrollment flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "a3995e67834828ddeabf14d1220547df84d870f72d743bed3cd3f27c57fefaf3c72d0e1cec85f65d48d9e5c8c7c539e3b636c2d96be94ffa3a979fb78e3e36fd",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:23.486786Z",
|
||||
"name": "Default - Source enrollment flow",
|
||||
"context": {},
|
||||
"pk": "9aa59b95-4e2c-48bf-87dc-96061d64509e",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-source-pre-authentication.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Source pre-authentication flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "51b6b65fb60f2f925d80b38cf173e7aa1bc00a074990e8d0b0dfaae088007ceb9051d0722f41500da309af5d0c78417dfa08a40c706203c3d6ee1eb613ad564c",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:29.374131Z",
|
||||
"name": "Default - Source pre-authentication flow",
|
||||
"context": {},
|
||||
"pk": "973eac96-d318-4a56-a1ab-532920e18388",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-authenticator-static-setup.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - Static MFA setup flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "2734d44185d0f37fe3af72cc28a9cc0bd06f8b160f021412571933f3062d0b47f056e832307c3de2367651eea43793e9a105d873917370717935bc66d765ea9c",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:42.374678Z",
|
||||
"name": "Default - Static MFA setup flow",
|
||||
"context": {},
|
||||
"pk": "a297b4a2-52fc-4f66-a952-5e2e9d51884b",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-authenticator-totp-setup.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - TOTP MFA setup flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "88836c8721b8b163584bd208ceb0a29ec5a44f56e749bdd44dabf541c331da99c57dfb128e06d8f073c4ddef0e1565eefa62696aae14f01ab4ff688eda277603",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:22.773974Z",
|
||||
"name": "Default - TOTP MFA setup flow",
|
||||
"context": {},
|
||||
"pk": "df7af2c7-0666-4e38-854a-4d291e27bba5",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-user-settings-flow.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - User settings flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "fde7a4686603bbca15824b1b412e7cd6b93cb051c0286d95f31708f66cdd1150e539c8be3d18a6c2ec0871bd77d4c8e4de5654844705e821ccfaee1434c25ac9",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:42.002441Z",
|
||||
"name": "Default - User settings flow",
|
||||
"context": {},
|
||||
"pk": "e260c804-6da9-4f93-b9b6-e10013594366",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "default/flow-default-authenticator-webauthn-setup.yaml",
|
||||
"metadata": {
|
||||
"name": "Default - WebAuthn MFA setup flow",
|
||||
"labels": {}
|
||||
},
|
||||
"last_applied_hash": "7ec577d44f40616ac216be5039b6e0133af162c2dd9925adf51d49d15841be082ab9c98debc51a25ac1a12566a728f07845e16203446e8a724e457b5255c65c8",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:30.186106Z",
|
||||
"name": "Default - WebAuthn MFA setup flow",
|
||||
"context": {},
|
||||
"pk": "139ef4ed-28c1-447c-b859-787cd387d359",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "migrations/migrate-prompt-name.yaml",
|
||||
"metadata": {
|
||||
"name": "Migration - Remove old prompt fields",
|
||||
"labels": {"blueprints.goauthentik.io/description": "Migrate to 2023.2, remove unused prompt fields"}
|
||||
},
|
||||
"last_applied_hash": "75617432af2bb8d9cbf74db0ba9d6ec1b123811fdb40975685386c85d8fa4046cc4749a8e6f1f3e0c1068e45bf2af73914c6ede5b3d31fa2ac3195a53b94cf43",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:42.232707Z",
|
||||
"name": "Migration - Remove old prompt fields",
|
||||
"context": {},
|
||||
"pk": "05a1d36f-d5dd-496a-8336-b8b0452d13ad",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-google-workspace.yaml",
|
||||
"metadata": {
|
||||
"name": "System - Google Workspace Provider - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "43e8e572057af9709f3c30c076f20987c4a8ccab629cb0a15417979b5aa865d3993e5a03e485e7f7f3232a521844cf49285304dfc57d8355affdc4477b3d5933",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:41.548061Z",
|
||||
"name": "System - Google Workspace Provider - Mappings",
|
||||
"context": {},
|
||||
"pk": "4206aa46-241a-4603-86d5-3c30940be755",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/sources-kerberos.yaml",
|
||||
"metadata": {
|
||||
"name": "System - Kerberos Source - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "49db6f6c3ee0fba992b9ad6756b499393f4648db17592c988462fa6c8c312f4c6b2adcf4e3e1fd8653fe05ebc20c7cffa258f3959f872b9590369b74e860dca8",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:23.255310Z",
|
||||
"name": "System - Kerberos Source - Mappings",
|
||||
"context": {},
|
||||
"pk": "c1ed6282-df71-44cb-83d4-ab8b1cf11948",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/sources-ldap.yaml",
|
||||
"metadata": {
|
||||
"name": "System - LDAP Source - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "36a01c42520e0a7929dc62005dfd701099cbbf74527718c97ea2c800fd26c1c816474cc2b402729584914ec77bdc35e26bb62d74a9e521207eebd28e3524e8e4",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:41.721906Z",
|
||||
"name": "System - LDAP Source - Mappings",
|
||||
"context": {},
|
||||
"pk": "f5dc928e-aa22-4d32-a11e-0cfde1a9dc18",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-microsoft-entra.yaml",
|
||||
"metadata": {
|
||||
"name": "System - Microsoft Entra Provider - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "ad85e885bf0bbb0df2398b2a252f1f32f222140fbbd8df8abac83a8debedba7c57955d388330e1e22420679fe940167c2ed5b4c5a8c8cb6c742b0282b3b56732",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:26.771728Z",
|
||||
"name": "System - Microsoft Entra Provider - Mappings",
|
||||
"context": {},
|
||||
"pk": "7d903a64-096a-4961-8df3-5d1bdb414ef5",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-oauth2.yaml",
|
||||
"metadata": {
|
||||
"name": "System - OAuth2 Provider - Scopes",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "da8a4bde589c67be18de2df424e3cd175ef2babff6e14f1649003c83fd096e1bab7f39cbf6294b49d59acca24c7f64e35d8b76fcebebb2e82c6590903db1e88d",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:41.081341Z",
|
||||
"name": "System - OAuth2 Provider - Scopes",
|
||||
"context": {},
|
||||
"pk": "306554ca-93d9-4b62-9964-7956c8dd59b5",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/object-attributes-user.yaml",
|
||||
"metadata": {
|
||||
"name": "System - Object Attributes - User",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "dea2d6bb95198ebdb6fdda23cd5d8339105087841e9007919c92b446b98a70454d9512b882d8c884bdaf80aa926b7e91013da3239424a715d5c306ad4290d6eb",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:29.802102Z",
|
||||
"name": "System - Object Attributes - User",
|
||||
"context": {},
|
||||
"pk": "059b544b-1ce7-48fa-9d52-d7728fe0218d",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-proxy.yaml",
|
||||
"metadata": {
|
||||
"name": "System - Proxy Provider - Scopes",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "c92fa491977a1a528005dd11953a6c524ae91cbb8f390938a8083211df02428168c883e75ce3b4a76484cad89310633ef34f2a8a6a272b27e1a53914ea53a8d5",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:41.151981Z",
|
||||
"name": "System - Proxy Provider - Scopes",
|
||||
"context": {},
|
||||
"pk": "7327ddb8-a884-47dc-ba99-48b4623c1883",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-rac.yaml",
|
||||
"metadata": {
|
||||
"name": "System - RAC Provider - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "6332bda15401895bd478f07d4e6e6b7db90f335c61ba7d924ed8ab02bddb4bdf5f40c888b1acf24b5c51133705d825b323030461db3c46dfc4fb91d4609e5e76",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:26.989465Z",
|
||||
"name": "System - RAC Provider - Mappings",
|
||||
"context": {},
|
||||
"pk": "25ece34f-ae5a-4ed5-b94e-ade794143dae",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-saml.yaml",
|
||||
"metadata": {
|
||||
"name": "System - SAML Provider - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "4f77ca1c7729136332a04e89a12c713a72bdf08a766132c3092586cbf8580c51aafc6f3c1f6220dd2415b6c66646632980f2f3a424cb609d62df8680e3ea7b3e",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:30.003502Z",
|
||||
"name": "System - SAML Provider - Mappings",
|
||||
"context": {},
|
||||
"pk": "532b9d75-09a3-4fa1-8dfc-6f1ce6daf138",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/providers-scim.yaml",
|
||||
"metadata": {
|
||||
"name": "System - SCIM Provider - Mappings",
|
||||
"labels": {"blueprints.goauthentik.io/system": "true"}
|
||||
},
|
||||
"last_applied_hash": "4dfec958adf1eec36e394e0ca48b53448eca34e24e8082c745da5cd61a0fd8b0034ca340a29f4788046f2310773e7b1e79abd91650dd47cc2406f2aa510e9b9a",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:21.392651Z",
|
||||
"name": "System - SCIM Provider - Mappings",
|
||||
"context": {},
|
||||
"pk": "aca7b811-f3ed-4360-b52e-68e6ff2d104b",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "system/bootstrap.yaml",
|
||||
"metadata": {
|
||||
"name": "authentik Bootstrap",
|
||||
"labels": {
|
||||
"blueprints.goauthentik.io/system-bootstrap": "true",
|
||||
"blueprints.goauthentik.io/system": "true",
|
||||
"blueprints.goauthentik.io/description": "This blueprint configures the default admin user and group, and configures them for the [Automated install](https://docs.goauthentik.io/docs/install-config/automated-install?utm_source=bootstrap_blueprint).\n"
|
||||
}
|
||||
},
|
||||
"last_applied_hash": "a654534f252c03881ba290d278bfd57014c433799b02c6b521e054a1f62751aba67114cd6c16b89b031f98706e84279e4f5ccae4da6df8f21c5556096945e685",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:42.104201Z",
|
||||
"name": "authentik Bootstrap",
|
||||
"context": {},
|
||||
"pk": "0722bfd2-b49e-4cbb-a4f6-80b6f1c83f10",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "custom/kof22-identity-bootstrap.yaml",
|
||||
"metadata": {
|
||||
"name": "kof22-identity-bootstrap",
|
||||
"labels": {"blueprints.goauthentik.io/instantiate": "true"}
|
||||
},
|
||||
"last_applied_hash": "4958e74112d408a5c593c6c6164af852993180c859fbe3a494b77fd8b19dae69132f2fb3cf19dc71787f114e60b8515f26215ae2d8efb87a650823c038ba8acb",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:26.702006Z",
|
||||
"name": "kof22-identity-bootstrap",
|
||||
"context": {},
|
||||
"pk": "d68e6be0-4d09-4c0d-9c76-4199b0cc44f1",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
},
|
||||
{
|
||||
"path": "custom/kof22-oauth2-apps.yaml",
|
||||
"metadata": {
|
||||
"name": "kof22-oauth2-apps",
|
||||
"labels": {"blueprints.goauthentik.io/instantiate": "true"}
|
||||
},
|
||||
"last_applied_hash": "7113b8b72d619886c4a8c75a0ab83fe83b4949f77e8d0ceb3d521570064c1fac52b21fd5fa375e1c916648a2af147d74b3beac1ece199b5fb14d697745971119",
|
||||
"managed_models": [],
|
||||
"last_applied": "2026-10-12T01:15:28.407023Z",
|
||||
"name": "kof22-oauth2-apps",
|
||||
"context": {},
|
||||
"pk": "d9230137-f9fd-4a78-9132-fd20265670b2",
|
||||
"enabled": true,
|
||||
"content": "",
|
||||
"status": "successful"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 9,
|
||||
"end_index": 9,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {
|
||||
"models": {
|
||||
"authentik_events.event_brand": {
|
||||
"app": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"model_name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"pk": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_user": {
|
||||
"pk": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "int"
|
||||
},
|
||||
"email": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"username": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_context_http_request": {
|
||||
"args": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context_http_request_args"
|
||||
},
|
||||
"path": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"method": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"request_id": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"user_agent": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event": {
|
||||
"app": {
|
||||
"nullable": false,
|
||||
"options": [
|
||||
"authentik.core.api.tokens",
|
||||
"authentik.events.middleware",
|
||||
"authentik.events.models",
|
||||
"authentik.events.signals"
|
||||
],
|
||||
"type": "str"
|
||||
},
|
||||
"created": {
|
||||
"nullable": false,
|
||||
"options": [],
|
||||
"type": "datetime"
|
||||
},
|
||||
"context": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context"
|
||||
},
|
||||
"action": {
|
||||
"nullable": false,
|
||||
"options": [
|
||||
"login",
|
||||
"login_failed",
|
||||
"logout",
|
||||
"user_write",
|
||||
"user_offboarded",
|
||||
"suspicious_request",
|
||||
"password_set",
|
||||
"secret_view",
|
||||
"secret_rotate",
|
||||
"invitation_used",
|
||||
"authorize_application",
|
||||
"source_linked",
|
||||
"impersonation_started",
|
||||
"impersonation_ended",
|
||||
"flow_execution",
|
||||
"policy_execution",
|
||||
"policy_exception",
|
||||
"property_mapping_exception",
|
||||
"system_task_execution",
|
||||
"system_task_exception",
|
||||
"system_exception",
|
||||
"configuration_error",
|
||||
"configuration_warning",
|
||||
"model_created",
|
||||
"model_updated",
|
||||
"model_deleted",
|
||||
"email_sent",
|
||||
"update_available",
|
||||
"export_ready",
|
||||
"review_initiated",
|
||||
"review_overdue",
|
||||
"review_attested",
|
||||
"review_completed",
|
||||
"access_request_created",
|
||||
"access_request_approved",
|
||||
"access_request_denied",
|
||||
"access_request_revoked",
|
||||
"custom_"
|
||||
],
|
||||
"type": "str"
|
||||
},
|
||||
"client_ip": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"event_uuid": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"user": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_user"
|
||||
},
|
||||
"brand": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_brand"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_context": {"http_request": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context_http_request"
|
||||
}}
|
||||
},
|
||||
"current_model": "authentik_events.event"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:34.820256Z",
|
||||
"created": "2026-10-12T01:15:34.822932Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "floor-plain",
|
||||
"pk": "65283c7b44e844749cf3e61b54fa725c"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/remove_user/",
|
||||
"method": "POST",
|
||||
"request_id": "5aaa9626c90a4e0ead7d0d5ea3515933",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "0a8aa038-5571-4a96-bc76-4a4045fb50bc",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:34.693177Z",
|
||||
"created": "2026-10-12T01:15:34.695787Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "floor-plain",
|
||||
"pk": "65283c7b44e844749cf3e61b54fa725c"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/add_user/",
|
||||
"method": "POST",
|
||||
"request_id": "7e3392719cb7460fb2fa98391d9ebeec",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "59a6957e-d6d3-4a0e-b561-0f5a824c4a8f",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.core.api.tokens",
|
||||
"expires": "2027-10-12T01:15:33.977233Z",
|
||||
"created": "2026-10-12T01:15:33.980004Z",
|
||||
"context": {
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/tokens/floor-token/view_key/",
|
||||
"method": "GET",
|
||||
"request_id": "530cfcb3ea0a4ce2acf64c467db7436a",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
},
|
||||
"secret": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "token",
|
||||
"name": "floor-token (expires=2026-10-13 01:15:33.719575+00:00)",
|
||||
"pk": "aca8159c23c7473f9f3e7b45fc044b06"
|
||||
}
|
||||
},
|
||||
"action": "secret_view",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "7d0cca6d-3ad1-4f0c-aa06-9fe7112c7fe4",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:33.771579Z",
|
||||
"created": "2026-10-12T01:15:33.772503Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_rbac",
|
||||
"model_name": "role",
|
||||
"name": "ak-managed-role--user-7",
|
||||
"pk": "71d5471a605249b9aa3559bfa7fd4f6d"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/tokens/",
|
||||
"method": "POST",
|
||||
"request_id": "f39d83318d7e4a06817abaf3d6357988",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_created",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "1b01b1f9-5228-45e2-91b1-e270279ee397",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:33.729317Z",
|
||||
"created": "2026-10-12T01:15:33.732414Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "token",
|
||||
"name": "floor-token (expires=2026-10-13 01:15:33.719575+00:00)",
|
||||
"pk": "aca8159c23c7473f9f3e7b45fc044b06"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/tokens/",
|
||||
"method": "POST",
|
||||
"request_id": "f39d83318d7e4a06817abaf3d6357988",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_created",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "d30f3cb1-b35b-4869-babd-b3cf44697d4f",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.746918Z",
|
||||
"created": "2026-10-12T01:15:32.749840Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "authentik Admins renamed",
|
||||
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
|
||||
"method": "PATCH",
|
||||
"request_id": "3d62eb9053564fa1b247206000ccff05",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "d7e2aa37-b6bf-40e8-961f-e3fb0d38f96c",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.504575Z",
|
||||
"created": "2026-10-12T01:15:32.507024Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "user",
|
||||
"name": "Break-glass admin",
|
||||
"pk": 8
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/users/8/",
|
||||
"method": "PATCH",
|
||||
"request_id": "ba40dbf9447543848251e14466a48732",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "a1fb6a93-49cb-4c16-bf24-4b6f192b1ce0",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.263662Z",
|
||||
"created": "2026-10-12T01:15:32.266453Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "user",
|
||||
"name": "Break-glass admin",
|
||||
"pk": 8
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/users/8/",
|
||||
"method": "PATCH",
|
||||
"request_id": "7471afd5942b40edad59ce82ed77157c",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "180a4778-1744-44f3-8f98-835962a0ff0f",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:31.970674Z",
|
||||
"created": "2026-10-12T01:15:31.974242Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "authentik Admins",
|
||||
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/remove_user/",
|
||||
"method": "POST",
|
||||
"request_id": "d473c1ed6fbf4b9cb963825cdde998af",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "741fe6cd-5972-4182-99c5-666c9c217a14",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 2,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 14,
|
||||
"end_index": 10,
|
||||
"total_pages": 2
|
||||
},
|
||||
"autocomplete": {
|
||||
"models": {
|
||||
"authentik_events.event_brand": {
|
||||
"app": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"model_name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"pk": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_user": {
|
||||
"pk": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "int"
|
||||
},
|
||||
"email": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"username": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_context_http_request": {
|
||||
"args": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context_http_request_args"
|
||||
},
|
||||
"path": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"method": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"request_id": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"user_agent": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"authentik_events.event": {
|
||||
"app": {
|
||||
"nullable": false,
|
||||
"options": [
|
||||
"authentik.core.api.tokens",
|
||||
"authentik.events.middleware",
|
||||
"authentik.events.models",
|
||||
"authentik.events.signals"
|
||||
],
|
||||
"type": "str"
|
||||
},
|
||||
"created": {
|
||||
"nullable": false,
|
||||
"options": [],
|
||||
"type": "datetime"
|
||||
},
|
||||
"context": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context"
|
||||
},
|
||||
"action": {
|
||||
"nullable": false,
|
||||
"options": [
|
||||
"login",
|
||||
"login_failed",
|
||||
"logout",
|
||||
"user_write",
|
||||
"user_offboarded",
|
||||
"suspicious_request",
|
||||
"password_set",
|
||||
"secret_view",
|
||||
"secret_rotate",
|
||||
"invitation_used",
|
||||
"authorize_application",
|
||||
"source_linked",
|
||||
"impersonation_started",
|
||||
"impersonation_ended",
|
||||
"flow_execution",
|
||||
"policy_execution",
|
||||
"policy_exception",
|
||||
"property_mapping_exception",
|
||||
"system_task_execution",
|
||||
"system_task_exception",
|
||||
"system_exception",
|
||||
"configuration_error",
|
||||
"configuration_warning",
|
||||
"model_created",
|
||||
"model_updated",
|
||||
"model_deleted",
|
||||
"email_sent",
|
||||
"update_available",
|
||||
"export_ready",
|
||||
"review_initiated",
|
||||
"review_overdue",
|
||||
"review_attested",
|
||||
"review_completed",
|
||||
"access_request_created",
|
||||
"access_request_approved",
|
||||
"access_request_denied",
|
||||
"access_request_revoked",
|
||||
"custom_"
|
||||
],
|
||||
"type": "str"
|
||||
},
|
||||
"client_ip": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"event_uuid": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"user": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_user"
|
||||
},
|
||||
"brand": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_brand"
|
||||
}
|
||||
},
|
||||
"authentik_events.event_context": {"http_request": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_events.event_context_http_request"
|
||||
}}
|
||||
},
|
||||
"current_model": "authentik_events.event"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:41.304242Z",
|
||||
"created": "2026-10-12T01:15:41.305079Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "phase1-bad-attributes",
|
||||
"pk": "9f6f47fc18aa4a2da8a1874ebc93939a"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/",
|
||||
"method": "POST",
|
||||
"request_id": "a5c1ff0a98474e90a8656fffe77aaf44",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_created",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "1e29dbee-b80b-4a4a-ad40-15a4e44c15a4",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:41.196525Z",
|
||||
"created": "2026-10-12T01:15:41.197572Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "phase1-child-of-identity-admins",
|
||||
"pk": "a5c199450e4641bb85b68853eb3f375d"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/a5c19945-0e46-41bb-85b6-8853eb3f375d/add_user/",
|
||||
"method": "POST",
|
||||
"request_id": "cb9cc4cf6a75443281704de20b1303a7",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "fcf4c969-8ad9-485c-92e0-1c9b5d1b5b34",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:40.707696Z",
|
||||
"created": "2026-10-12T01:15:40.708545Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "phase1-child-of-identity-admins",
|
||||
"pk": "a5c199450e4641bb85b68853eb3f375d"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/",
|
||||
"method": "POST",
|
||||
"request_id": "a761aec34f40435d83613d6745641069",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "e411eff2-6675-4f88-89c7-91821e7f666d",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:40.670838Z",
|
||||
"created": "2026-10-12T01:15:40.671750Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "phase1-child-of-identity-admins",
|
||||
"pk": "a5c199450e4641bb85b68853eb3f375d"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/",
|
||||
"method": "POST",
|
||||
"request_id": "a761aec34f40435d83613d6745641069",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_created",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "cd6f91a5-dd47-4caf-84af-dd121bc9eb16",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:36.198409Z",
|
||||
"created": "2026-10-12T01:15:36.199330Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "kof-website-publishers",
|
||||
"pk": "014cb7a18c864990a92cedf6985939a7"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/014cb7a1-8c86-4990-a92c-edf6985939a7/add_user/",
|
||||
"method": "POST",
|
||||
"request_id": "0b6d047cc050411a943769a8407f4800",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "f29fd3cb-2dc4-45bf-99d7-8d261459f930",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:34.820256Z",
|
||||
"created": "2026-10-12T01:15:34.822932Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "floor-plain",
|
||||
"pk": "65283c7b44e844749cf3e61b54fa725c"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/remove_user/",
|
||||
"method": "POST",
|
||||
"request_id": "5aaa9626c90a4e0ead7d0d5ea3515933",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "0a8aa038-5571-4a96-bc76-4a4045fb50bc",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:34.693177Z",
|
||||
"created": "2026-10-12T01:15:34.695787Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "floor-plain",
|
||||
"pk": "65283c7b44e844749cf3e61b54fa725c"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/65283c7b-44e8-4474-9cf3-e61b54fa725c/add_user/",
|
||||
"method": "POST",
|
||||
"request_id": "7e3392719cb7460fb2fa98391d9ebeec",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "59a6957e-d6d3-4a0e-b561-0f5a824c4a8f",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.863998Z",
|
||||
"created": "2026-10-12T01:15:32.864931Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "authentik Admins",
|
||||
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
|
||||
"method": "PATCH",
|
||||
"request_id": "1bb86894bd8d4941b35b2b982b0b0e67",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "e402f70e-3cab-4532-aed4-1f29ac66fbc8",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.746918Z",
|
||||
"created": "2026-10-12T01:15:32.749840Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "authentik Admins renamed",
|
||||
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/",
|
||||
"method": "PATCH",
|
||||
"request_id": "3d62eb9053564fa1b247206000ccff05",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "d7e2aa37-b6bf-40e8-961f-e3fb0d38f96c",
|
||||
"user": {
|
||||
"pk": 7,
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"app": "authentik.events.middleware",
|
||||
"expires": "2027-10-12T01:15:32.126855Z",
|
||||
"created": "2026-10-12T01:15:32.127799Z",
|
||||
"context": {
|
||||
"model": {
|
||||
"app": "authentik_core",
|
||||
"model_name": "group",
|
||||
"name": "authentik Admins",
|
||||
"pk": "c4e3b3ea2dca4f4babfee83f4784a302"
|
||||
},
|
||||
"http_request": {
|
||||
"args": {},
|
||||
"path": "/api/v3/core/groups/c4e3b3ea-2dca-4f4b-abfe-e83f4784a302/add_user/",
|
||||
"method": "POST",
|
||||
"request_id": "95bf776e7597468f8004474d067569cd",
|
||||
"user_agent": "Java-http-client/21.0.12.1"
|
||||
}
|
||||
},
|
||||
"action": "model_updated",
|
||||
"client_ip": "::ffff:172.26.0.1",
|
||||
"pk": "fcfb303a-ee9f-483c-bd58-c82090ab1d2f",
|
||||
"user": {
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
"brand": {
|
||||
"app": "authentik_brands",
|
||||
"model_name": "brand",
|
||||
"name": "Default brand",
|
||||
"pk": "82bdd9761ed545b2a24e1d2d30ddd4ac"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 1,
|
||||
"end_index": 1,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {
|
||||
"models": {
|
||||
"authentik_core.group": {
|
||||
"is_superuser": {
|
||||
"nullable": true,
|
||||
"options": null,
|
||||
"type": "bool"
|
||||
},
|
||||
"name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"attributes": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_core.group_attributes"
|
||||
}
|
||||
},
|
||||
"authentik_core.group_attributes": {}
|
||||
},
|
||||
"current_model": "authentik_core.group"
|
||||
},
|
||||
"results": [{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 22032,
|
||||
"users_obj": [{
|
||||
"uid": "20731f37d1294a501fbd54f5092ea9b81f03fda1349b2394aa893873670ec195",
|
||||
"is_active": true,
|
||||
"last_login": null,
|
||||
"name": "Phase One",
|
||||
"attributes": {},
|
||||
"pk": 14,
|
||||
"email": "phase1@example.test",
|
||||
"username": "phase1.member"
|
||||
}],
|
||||
"users": [14],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "phase1-child-of-identity-admins",
|
||||
"attributes": {},
|
||||
"pk": "a5c19945-0e46-41bb-85b6-8853eb3f375d",
|
||||
"children_obj": null,
|
||||
"parents": ["2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed"]
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 11,
|
||||
"end_index": 11,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {
|
||||
"models": {
|
||||
"authentik_core.group": {
|
||||
"is_superuser": {
|
||||
"nullable": true,
|
||||
"options": null,
|
||||
"type": "bool"
|
||||
},
|
||||
"name": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "str"
|
||||
},
|
||||
"attributes": {
|
||||
"nullable": false,
|
||||
"options": null,
|
||||
"type": "relation",
|
||||
"relation": "authentik_core.group_attributes"
|
||||
}
|
||||
},
|
||||
"authentik_core.group_attributes": {}
|
||||
},
|
||||
"current_model": "authentik_core.group"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": true,
|
||||
"roles": [],
|
||||
"num_pk": 26171,
|
||||
"users_obj": [
|
||||
{
|
||||
"uid": "f36b1954a79a34292da5065aa22c92661cbfee796a6683f4550f24ce05d2ea9c",
|
||||
"is_active": true,
|
||||
"last_login": "2026-10-12T01:15:42.211224Z",
|
||||
"name": "authentik Default Admin",
|
||||
"attributes": {},
|
||||
"pk": 4,
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
{
|
||||
"uid": "5ec4f8d482fa84b43d02d51827dbcac6d160fcd7e058acabfde3cf935e03184a",
|
||||
"is_active": true,
|
||||
"last_login": null,
|
||||
"name": "Break-glass admin",
|
||||
"attributes": {"qqq.io/protected": true},
|
||||
"pk": 8,
|
||||
"email": "",
|
||||
"username": "kof22-breakglass"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"parents_obj": null,
|
||||
"children": ["db725d12-8ed2-46c8-8f98-3a43325302a6"],
|
||||
"name": "authentik Admins",
|
||||
"attributes": {},
|
||||
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [{
|
||||
"name": "authentik Agent-Users",
|
||||
"pk": "d6e97495-fe8d-4084-9c12-ccc466afa21d"
|
||||
}],
|
||||
"is_superuser": false,
|
||||
"roles": ["d6e97495-fe8d-4084-9c12-ccc466afa21d"],
|
||||
"num_pk": 23858,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "authentik Agent-Users",
|
||||
"attributes": {"notes": "A group with an auto-generated role that allows its members to create agent users.\n"},
|
||||
"pk": "b37d9565-490f-47aa-9d00-b8b1470241be",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [{
|
||||
"name": "authentik Read-only",
|
||||
"pk": "4db176bf-888d-47f9-913d-448ded912eb8"
|
||||
}],
|
||||
"is_superuser": false,
|
||||
"roles": ["4db176bf-888d-47f9-913d-448ded912eb8"],
|
||||
"num_pk": 32585,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "authentik Read-only",
|
||||
"attributes": {"notes": "A group with an auto-generated role that allows read-only permissions on all objects.\n"},
|
||||
"pk": "f525c9f5-6e92-4694-af30-93efcbafd4b2",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 29169,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "floor-child-of-admins",
|
||||
"attributes": {},
|
||||
"pk": "db725d12-8ed2-46c8-8f98-3a43325302a6",
|
||||
"children_obj": null,
|
||||
"parents": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"]
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 13446,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "floor-plain",
|
||||
"attributes": {},
|
||||
"pk": "65283c7b-44e8-4474-9cf3-e61b54fa725c",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 62033,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": ["a5c19945-0e46-41bb-85b6-8853eb3f375d"],
|
||||
"name": "identity-admins",
|
||||
"attributes": {
|
||||
"qqq.io/app-access": ["kof22-website-prod"],
|
||||
"qqq.io/permissions": ["identity.*"]
|
||||
},
|
||||
"pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 20911,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "kof-website-drafters",
|
||||
"attributes": {},
|
||||
"pk": "9d517d04-2803-41e2-bf9f-d6714d290b90",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 17275,
|
||||
"users_obj": [{
|
||||
"uid": "dcc9281974526cbcdab77e51b5fb506584e8e7d954ae55bca93f5760a91cc8dc",
|
||||
"is_active": true,
|
||||
"last_login": null,
|
||||
"name": "Read Probe 1",
|
||||
"attributes": {},
|
||||
"pk": 10,
|
||||
"email": "read.probe1@example.test",
|
||||
"username": "read.probe1"
|
||||
}],
|
||||
"users": [10],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "kof-website-publishers",
|
||||
"attributes": {},
|
||||
"pk": "014cb7a1-8c86-4990-a92c-edf6985939a7",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 10530,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "kof22-admins",
|
||||
"attributes": {},
|
||||
"pk": "4f39d0b0-228d-418d-8bf2-9c2c3f5595fd",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 21192,
|
||||
"users_obj": [],
|
||||
"users": [],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "phase1-bad-attributes",
|
||||
"attributes": {
|
||||
"qqq.io/app-access": ["not-in-scope-app"],
|
||||
"qqq.io/permissions": ["no.such.permission"]
|
||||
},
|
||||
"pk": "9f6f47fc-18aa-4a2d-a8a1-874ebc93939a",
|
||||
"children_obj": null,
|
||||
"parents": []
|
||||
},
|
||||
{
|
||||
"inherited_roles_obj": null,
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 22032,
|
||||
"users_obj": [{
|
||||
"uid": "20731f37d1294a501fbd54f5092ea9b81f03fda1349b2394aa893873670ec195",
|
||||
"is_active": true,
|
||||
"last_login": null,
|
||||
"name": "Phase One",
|
||||
"attributes": {},
|
||||
"pk": 14,
|
||||
"email": "phase1@example.test",
|
||||
"username": "phase1.member"
|
||||
}],
|
||||
"users": [14],
|
||||
"parents_obj": null,
|
||||
"children": [],
|
||||
"name": "phase1-child-of-identity-admins",
|
||||
"attributes": {},
|
||||
"pk": "a5c19945-0e46-41bb-85b6-8853eb3f375d",
|
||||
"children_obj": null,
|
||||
"parents": ["2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
"roles_obj": [],
|
||||
"is_superuser": false,
|
||||
"roles": [],
|
||||
"num_pk": 97374,
|
||||
"num_pk": 62033,
|
||||
"users_obj": null,
|
||||
"users": [],
|
||||
"parents_obj": [],
|
||||
@@ -47,7 +47,7 @@
|
||||
"qqq.io/app-access": ["kof22-website-prod"],
|
||||
"qqq.io/permissions": ["identity.*"]
|
||||
},
|
||||
"pk": "4941bc80-3f27-446f-b4d3-ba023f044b65",
|
||||
"pk": "2eab2c0f-14f0-474c-b27f-d02c3bfcb7ed",
|
||||
"children_obj": [],
|
||||
"parents": []
|
||||
}]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 1,
|
||||
"end_index": 1,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {},
|
||||
"results": [{
|
||||
"last_ip": "::ffff:172.26.0.1",
|
||||
"current": false,
|
||||
"expires": "2026-10-13T01:15:42.224550Z",
|
||||
"geo_ip": null,
|
||||
"last_used": "2026-10-12T01:15:42.225072Z",
|
||||
"last_user_agent": "Java-http-client/21.0.12.1",
|
||||
"uuid": "3d4611d5-ea75-4e48-b1f2-cdd6b5145a86",
|
||||
"asn": null,
|
||||
"user": 4,
|
||||
"user_agent": {
|
||||
"string": "Java-http-client/21.0.12.1",
|
||||
"os": {
|
||||
"patch": null,
|
||||
"patch_minor": null,
|
||||
"major": null,
|
||||
"minor": null,
|
||||
"family": "Other"
|
||||
},
|
||||
"device": {
|
||||
"model": null,
|
||||
"family": "Other",
|
||||
"brand": null
|
||||
},
|
||||
"user_agent": {
|
||||
"patch": null,
|
||||
"major": null,
|
||||
"minor": null,
|
||||
"family": "Other"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 1,
|
||||
"end_index": 1,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {},
|
||||
"results": [{
|
||||
"identifier": "qqq-contract-test",
|
||||
"user_obj": {
|
||||
"roles_obj": [
|
||||
{
|
||||
"name": "qqq-kof22-admin-idadmin",
|
||||
"pk": "f8a6aea0-d491-4fd7-87cd-9764175bb5f2"
|
||||
},
|
||||
{
|
||||
"name": "ak-managed-role--user-7",
|
||||
"pk": "71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
|
||||
}
|
||||
],
|
||||
"last_updated": "2026-10-12T01:15:26.584286Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T01:15:26.571298Z",
|
||||
"roles": [
|
||||
"f8a6aea0-d491-4fd7-87cd-9764175bb5f2",
|
||||
"71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
|
||||
],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "service_account",
|
||||
"groups_obj": [],
|
||||
"uuid": "7035a7ac-e5f7-4b49-b35b-043d05471854",
|
||||
"uid": "917ea81f617d6f499ff98a9b32cc33d8023569e39f5ec156c1574d3a4c7b5c52",
|
||||
"path": "service-accounts/qqq",
|
||||
"name": "QQQ kof22 Admin identity service",
|
||||
"attributes": {"qqq.io/protected": true},
|
||||
"pk": 7,
|
||||
"date_joined": "2026-10-12T01:15:26.570216Z",
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
"expires": "2026-10-13T01:15:30.631896Z",
|
||||
"expiring": false,
|
||||
"managed": null,
|
||||
"description": "",
|
||||
"pk": "fe0ddb9c-aca3-456b-acc8-49541e746f60",
|
||||
"intent": "api",
|
||||
"user": 7
|
||||
}]
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:44.589851Z",
|
||||
"last_updated": "2026-10-12T01:15:35.431590Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:44.548432Z",
|
||||
"password_change_date": "2026-10-12T01:15:35.388242Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
|
||||
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
|
||||
"uuid": "9a78e0c0-5514-44c7-9c95-bf8f1d0f6722",
|
||||
"uid": "90f11d41d0bf311145bda65760bd48cfa71a32a90637dc44900157f75eb27d67",
|
||||
"path": "users",
|
||||
"name": "Read Probe 2",
|
||||
"attributes": {},
|
||||
"pk": 17,
|
||||
"date_joined": "2026-10-12T00:42:44.548001Z",
|
||||
"pk": 11,
|
||||
"date_joined": "2026-10-12T01:15:35.387796Z",
|
||||
"email": "read.probe2@example.test",
|
||||
"username": "read.probe2"
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
"current": 1,
|
||||
"previous": 0,
|
||||
"start_index": 1,
|
||||
"count": 12,
|
||||
"end_index": 12,
|
||||
"count": 8,
|
||||
"end_index": 8,
|
||||
"total_pages": 1
|
||||
},
|
||||
"autocomplete": {
|
||||
@@ -60,321 +60,209 @@
|
||||
"results": [
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:38.179289Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:38.131278Z",
|
||||
"roles": [],
|
||||
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [{
|
||||
"is_superuser": false,
|
||||
"name": "kof-website-publishers",
|
||||
"attributes": {},
|
||||
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
|
||||
"num_pk": 26548
|
||||
}],
|
||||
"uuid": "6656101d-7d44-4b7f-aeb5-257b13a4447f",
|
||||
"uid": "41758f45cd5b97f58994f51764097ce15a3b6daaad23a2aaadc3a47325370edb",
|
||||
"path": "users",
|
||||
"name": "Access Publisher",
|
||||
"attributes": {},
|
||||
"pk": 14,
|
||||
"date_joined": "2026-10-12T00:42:38.130713Z",
|
||||
"email": "",
|
||||
"username": "access.publisher"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:41:39.098819Z",
|
||||
"last_updated": "2026-10-12T01:15:15.501529Z",
|
||||
"is_active": true,
|
||||
"is_superuser": true,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:41:38.964003Z",
|
||||
"password_change_date": "2026-10-12T01:15:15.391962Z",
|
||||
"roles": [],
|
||||
"groups": [
|
||||
"99da4fa1-41b7-41ed-9a09-536682c4589b",
|
||||
"6b62508d-14e8-4cf6-9783-682d7c94af12"
|
||||
],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [
|
||||
{
|
||||
"is_superuser": true,
|
||||
"name": "authentik Admins",
|
||||
"attributes": {},
|
||||
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
|
||||
"num_pk": 20450
|
||||
},
|
||||
{
|
||||
"is_superuser": false,
|
||||
"name": "lint-demo-group",
|
||||
"attributes": {"note": "declared in git"},
|
||||
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
|
||||
"num_pk": 14273
|
||||
}
|
||||
],
|
||||
"uuid": "96235ac9-286c-4a6a-ae21-4df0dab33315",
|
||||
"uid": "dee4b27045f6d9ad52b880c654fbdafc4c3ce86c45d3f045e5a638ea3c4b0065",
|
||||
"path": "users",
|
||||
"name": "authentik Default Admin",
|
||||
"attributes": {},
|
||||
"pk": 4,
|
||||
"date_joined": "2026-10-12T00:41:38.934836Z",
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:40.507175Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:40.455393Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "e933b7a1-db0d-4d7f-af92-ebca960e04ce",
|
||||
"uid": "5f82699a0cd33c6c02a73fcd8cc3a995302a22e28881733fa3dca7ef2dfff0ac",
|
||||
"path": "users",
|
||||
"name": "Floor Probe",
|
||||
"attributes": {},
|
||||
"pk": 15,
|
||||
"date_joined": "2026-10-12T00:42:40.454755Z",
|
||||
"email": "",
|
||||
"username": "floor.probe"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:41.985973Z",
|
||||
"is_active": true,
|
||||
"is_superuser": true,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:41:47.932445Z",
|
||||
"roles": [],
|
||||
"groups": ["99da4fa1-41b7-41ed-9a09-536682c4589b"],
|
||||
"groups": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [{
|
||||
"is_superuser": true,
|
||||
"name": "authentik Admins",
|
||||
"attributes": {},
|
||||
"pk": "99da4fa1-41b7-41ed-9a09-536682c4589b",
|
||||
"num_pk": 20450
|
||||
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
|
||||
"num_pk": 26171
|
||||
}],
|
||||
"uuid": "f72814ed-7574-4d20-bbcb-56545e39adab",
|
||||
"uid": "67d171fe0aa7735f081344377ef53325b135026db1544efcb8e1a3c7d236cdf2",
|
||||
"path": "users/breakglass",
|
||||
"name": "Break-glass admin",
|
||||
"attributes": {"qqq.io/protected": true},
|
||||
"pk": 8,
|
||||
"date_joined": "2026-10-12T00:41:47.930197Z",
|
||||
"email": "",
|
||||
"username": "kof22-breakglass"
|
||||
"uuid": "775c03ef-72db-41d5-a71f-3142948a3182",
|
||||
"uid": "f36b1954a79a34292da5065aa22c92661cbfee796a6683f4550f24ce05d2ea9c",
|
||||
"path": "users",
|
||||
"name": "authentik Default Admin",
|
||||
"attributes": {},
|
||||
"pk": 4,
|
||||
"date_joined": "2026-10-12T01:15:15.366364Z",
|
||||
"email": "akadmin@example.test",
|
||||
"username": "akadmin"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:19.183065Z",
|
||||
"last_updated": "2026-10-12T01:15:31.201219Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:19.139382Z",
|
||||
"password_change_date": "2026-10-12T01:15:31.158880Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "1cca8899-bf67-4acb-8231-b82f48b61549",
|
||||
"uid": "84030aa88ef5db17880ead3e60f4830246bdf0a7e40c71599a047ddf3cbac5ea",
|
||||
"uuid": "50debb28-392e-41dc-a838-3b9f266e854c",
|
||||
"uid": "b85d8f4b476635ed7ce070255951399c81c8a3385f7728ffd63e5c371db88864",
|
||||
"path": "users",
|
||||
"name": "Added Via API",
|
||||
"name": "Floor Probe",
|
||||
"attributes": {},
|
||||
"pk": 12,
|
||||
"date_joined": "2026-10-12T00:42:19.138961Z",
|
||||
"pk": 9,
|
||||
"date_joined": "2026-10-12T01:15:31.158474Z",
|
||||
"email": "",
|
||||
"username": "lint.added"
|
||||
"username": "floor.probe"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:20.680396Z",
|
||||
"last_updated": "2026-10-12T01:15:32.607834Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"is_superuser": true,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:18.514303Z",
|
||||
"password_change_date": "2026-10-12T01:15:26.678105Z",
|
||||
"roles": [],
|
||||
"groups": ["6b62508d-14e8-4cf6-9783-682d7c94af12"],
|
||||
"groups": ["c4e3b3ea-2dca-4f4b-abfe-e83f4784a302"],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [{
|
||||
"is_superuser": false,
|
||||
"name": "lint-demo-group",
|
||||
"attributes": {"note": "declared in git"},
|
||||
"pk": "6b62508d-14e8-4cf6-9783-682d7c94af12",
|
||||
"num_pk": 14273
|
||||
"is_superuser": true,
|
||||
"name": "authentik Admins",
|
||||
"attributes": {},
|
||||
"pk": "c4e3b3ea-2dca-4f4b-abfe-e83f4784a302",
|
||||
"num_pk": 26171
|
||||
}],
|
||||
"uuid": "db22814d-9610-429b-ab01-9ca7fdd6a3dd",
|
||||
"uid": "bb030a055fdd722735c72012ae24e9142e3626875be2ed6e82994079674b009f",
|
||||
"path": "users",
|
||||
"name": "Lint Demo User",
|
||||
"attributes": {},
|
||||
"pk": 11,
|
||||
"date_joined": "2026-10-12T00:42:18.512951Z",
|
||||
"uuid": "02a868ae-c72a-4272-b621-e0b9f455a1ae",
|
||||
"uid": "5ec4f8d482fa84b43d02d51827dbcac6d160fcd7e058acabfde3cf935e03184a",
|
||||
"path": "users/breakglass",
|
||||
"name": "Break-glass admin",
|
||||
"attributes": {"qqq.io/protected": true},
|
||||
"pk": 8,
|
||||
"date_joined": "2026-10-12T01:15:26.676488Z",
|
||||
"email": "",
|
||||
"username": "lint.demo.user"
|
||||
"username": "kof22-breakglass"
|
||||
},
|
||||
{
|
||||
"roles_obj": [
|
||||
{
|
||||
"name": "qqq-kof22-admin-idadmin",
|
||||
"pk": "2de63138-3566-48e7-9cf2-913a70b2c06f"
|
||||
"pk": "f8a6aea0-d491-4fd7-87cd-9764175bb5f2"
|
||||
},
|
||||
{
|
||||
"name": "ak-managed-role--user-7",
|
||||
"pk": "ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
|
||||
"pk": "71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
|
||||
}
|
||||
],
|
||||
"last_updated": "2026-10-12T00:41:47.841974Z",
|
||||
"last_updated": "2026-10-12T01:15:26.584286Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:41:47.831570Z",
|
||||
"password_change_date": "2026-10-12T01:15:26.571298Z",
|
||||
"roles": [
|
||||
"2de63138-3566-48e7-9cf2-913a70b2c06f",
|
||||
"ea22584a-d801-4ebc-aa2f-6cbb1f0ba9f7"
|
||||
"f8a6aea0-d491-4fd7-87cd-9764175bb5f2",
|
||||
"71d5471a-6052-49b9-aa35-59bfa7fd4f6d"
|
||||
],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "service_account",
|
||||
"groups_obj": [],
|
||||
"uuid": "63ac79e2-ce5c-46d1-9209-8210ad2b4b9e",
|
||||
"uid": "f15497ad81cf34b68a067048a07bf0ca199d9d1b8a4afa24c26b8d7211bac66d",
|
||||
"uuid": "7035a7ac-e5f7-4b49-b35b-043d05471854",
|
||||
"uid": "917ea81f617d6f499ff98a9b32cc33d8023569e39f5ec156c1574d3a4c7b5c52",
|
||||
"path": "service-accounts/qqq",
|
||||
"name": "QQQ kof22 Admin identity service",
|
||||
"attributes": {"qqq.io/protected": true},
|
||||
"pk": 7,
|
||||
"date_joined": "2026-10-12T00:41:47.829884Z",
|
||||
"date_joined": "2026-10-12T01:15:26.570216Z",
|
||||
"email": "",
|
||||
"username": "qqq-kof22-admin-idadmin"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:44.388384Z",
|
||||
"last_updated": "2026-10-12T01:15:35.225848Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:44.335235Z",
|
||||
"password_change_date": "2026-10-12T01:15:35.181395Z",
|
||||
"roles": [],
|
||||
"groups": ["c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd"],
|
||||
"groups": ["014cb7a1-8c86-4990-a92c-edf6985939a7"],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [{
|
||||
"is_superuser": false,
|
||||
"name": "kof-website-publishers",
|
||||
"attributes": {},
|
||||
"pk": "c7bb0c43-3b6d-47e1-8812-c3fbf41fe6bd",
|
||||
"num_pk": 26548
|
||||
"pk": "014cb7a1-8c86-4990-a92c-edf6985939a7",
|
||||
"num_pk": 17275
|
||||
}],
|
||||
"uuid": "7f8691f0-1ec0-42da-a24f-cb71bcb64c17",
|
||||
"uid": "07df95d4db5f9cd781eac774b3b8129b7bf9ee3106a4c9a2f830d5fbdb85c583",
|
||||
"uuid": "806d627d-b4f9-4ccc-abc9-e900555b2294",
|
||||
"uid": "dcc9281974526cbcdab77e51b5fb506584e8e7d954ae55bca93f5760a91cc8dc",
|
||||
"path": "users",
|
||||
"name": "Read Probe 1",
|
||||
"attributes": {},
|
||||
"pk": 16,
|
||||
"date_joined": "2026-10-12T00:42:44.334765Z",
|
||||
"pk": 10,
|
||||
"date_joined": "2026-10-12T01:15:35.180911Z",
|
||||
"email": "read.probe1@example.test",
|
||||
"username": "read.probe1"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:44.589851Z",
|
||||
"last_updated": "2026-10-12T01:15:35.431590Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:44.548432Z",
|
||||
"password_change_date": "2026-10-12T01:15:35.388242Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "b99e966f-61bb-4f9f-b136-b747d5fb68f7",
|
||||
"uid": "77947ea5357373462f042427a96e83bb14ea799dfae76daf6a014d01fc4580ce",
|
||||
"uuid": "9a78e0c0-5514-44c7-9c95-bf8f1d0f6722",
|
||||
"uid": "90f11d41d0bf311145bda65760bd48cfa71a32a90637dc44900157f75eb27d67",
|
||||
"path": "users",
|
||||
"name": "Read Probe 2",
|
||||
"attributes": {},
|
||||
"pk": 17,
|
||||
"date_joined": "2026-10-12T00:42:44.548001Z",
|
||||
"pk": 11,
|
||||
"date_joined": "2026-10-12T01:15:35.387796Z",
|
||||
"email": "read.probe2@example.test",
|
||||
"username": "read.probe2"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:44.790626Z",
|
||||
"last_updated": "2026-10-12T01:15:35.630722Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:44.740536Z",
|
||||
"password_change_date": "2026-10-12T01:15:35.588222Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "9cb596e1-ac25-4c9c-aa49-e9a66e07a5fd",
|
||||
"uid": "961c6ec5f4e73114ab10c834f18cf279fb6a61b53144302674144c73f7194932",
|
||||
"uuid": "22d18ca3-f66b-4f5f-a671-2dceeba29940",
|
||||
"uid": "19ec3e5551d54398faa4d0de1e894e0ad9bba962f77a65eb821e5e1ee387b094",
|
||||
"path": "users",
|
||||
"name": "Read Probe 3",
|
||||
"attributes": {},
|
||||
"pk": 18,
|
||||
"date_joined": "2026-10-12T00:42:44.740046Z",
|
||||
"pk": 12,
|
||||
"date_joined": "2026-10-12T01:15:35.587830Z",
|
||||
"email": "read.probe3@example.test",
|
||||
"username": "read.probe3"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:45.576270Z",
|
||||
"last_updated": "2026-10-12T01:15:36.387682Z",
|
||||
"is_active": false,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:44.997114Z",
|
||||
"password_change_date": "2026-10-12T01:15:35.806263Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "9c523de5-9a37-4510-85d3-4cb23a8ba6df",
|
||||
"uid": "41ae72db125e854553e33f6ca481dc2dc2194fe5b0154a44a2f3cd0482e281b9",
|
||||
"uuid": "3ae17938-316f-4581-b9b0-3dde5a06c5d8",
|
||||
"uid": "59bb228eca9f1811bf03086191d87a018c52f9b503de895e72c41e8569554061",
|
||||
"path": "users",
|
||||
"name": "Read Probe 4",
|
||||
"attributes": {},
|
||||
"pk": 19,
|
||||
"date_joined": "2026-10-12T00:42:44.996692Z",
|
||||
"pk": 13,
|
||||
"date_joined": "2026-10-12T01:15:35.805761Z",
|
||||
"email": "read.probe4@example.test",
|
||||
"username": "read.probe4"
|
||||
},
|
||||
{
|
||||
"roles_obj": [],
|
||||
"last_updated": "2026-10-12T00:42:22.763043Z",
|
||||
"is_active": true,
|
||||
"is_superuser": false,
|
||||
"last_login": null,
|
||||
"password_change_date": "2026-10-12T00:42:22.714165Z",
|
||||
"roles": [],
|
||||
"groups": [],
|
||||
"avatar": "",
|
||||
"type": "internal",
|
||||
"groups_obj": [],
|
||||
"uuid": "b6ec0c40-5bff-4cb3-8434-7fe74c55c879",
|
||||
"uid": "e3bf082dd81daa0f56be03530c403e048dce22e7a522e02594897674fdf3b51f",
|
||||
"path": "users",
|
||||
"name": "Reapply Member",
|
||||
"attributes": {},
|
||||
"pk": 13,
|
||||
"date_joined": "2026-10-12T00:42:22.713832Z",
|
||||
"email": "",
|
||||
"username": "reapply.member"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user