Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c677055200
|
||
|
|
17486b88d2 | ||
|
|
7d1227adf5 | ||
|
|
7df05f2324 | ||
|
|
28cb654d13 | ||
|
|
33cdaa1141 | ||
|
|
44a46a3cfc
|
||
|
|
dbae77da5b | ||
|
|
1d72ce3cec
|
||
|
|
b784bfd097
|
||
|
|
da92be126d | ||
|
|
86ff924ebd
|
||
|
|
1f12334f4f
|
||
|
|
995a5900aa | ||
|
|
4d6140f7be
|
||
|
|
265dbb0468 | ||
|
|
bc92be338f
|
||
|
|
69830a095e
|
||
|
|
33e53263c5 | ||
|
|
1d8a2a85c7
|
||
|
|
eba93e68ca | ||
|
|
0df7bb49e2
|
||
|
|
8c8ef9fd28 | ||
|
|
24a97823a9
|
||
|
|
fee7e2ad20
|
||
|
|
3b117cfff4
|
||
|
|
35d9886bbf
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
@@ -1,42 +1,16 @@
|
||||
# Weekly: does the newest Authentik release still have every operation and
|
||||
# query parameter qbit-authentik uses? Downloads the release's schema.yml from
|
||||
# goauthentik/authentik and runs SchemaDriftTest against it (no Docker, no
|
||||
# credentials). A failure means: review the release notes before upgrading.
|
||||
name: authentik-schema-drift
|
||||
|
||||
on:
|
||||
'on':
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
- cron: 17 6 * * 1
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Authentik version (e.g. 2026.10.0); empty = latest release"
|
||||
description: Authentik version (e.g. 2026.10.0); empty = latest release
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: maven
|
||||
- name: Resolve version and fetch its schema
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
version="${INPUT_VERSION}"
|
||||
if [ -z "$version" ]; then
|
||||
version=$(gh release view --repo goauthentik/authentik --json tagName -q .tagName | sed 's#^version/##')
|
||||
fi
|
||||
echo "Authentik $version"
|
||||
curl -fsSL "https://raw.githubusercontent.com/goauthentik/authentik/version/${version}/schema.yml" -o "$RUNNER_TEMP/schema.yml"
|
||||
echo "SCHEMA=$RUNNER_TEMP/schema.yml" >> "$GITHUB_ENV"
|
||||
- name: Drift test
|
||||
run: ./mvnw -B -q test -Dtest=SchemaDriftTest -Dsurefire.failIfNoSpecifiedTests=false -Dauthentik.schema.file="$SCHEMA"
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-schema-drift.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
with:
|
||||
version: ${{ inputs.version || '' }}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -4,6 +4,9 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
|
||||
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
|
||||
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
|
||||
- Replace expired token-delivery and credential-scope reflection bridges with the RC.7 APIs; retain conservative permission narrowing and explicit unavailable-delivery tests.
|
||||
- Ship the expanded, host-neutral QBIT help bundle using QQQ 4.1.0-RC.7 registration and validation, with coverage for optional features and live references. Preserve inline app/table help for hosts without help storage; document HOST bundle overrides.
|
||||
|
||||
@@ -17,6 +20,13 @@ All notable changes to this project are documented here. Format: [Keep a Changel
|
||||
- Service-account lifetime caps use the user attribute (`serviceAccountTokenLifetime`, default `days=365`), preserving other attributes and requiring no special group.
|
||||
- `TokenContractIT` on Authentik 2026.8.3 (9 tests) with the production token blueprint: token exchange as the user, the broker deny policy, the `qqq_token` claim, PATCH defaults, the expiry cap, service-account create/set_key/extend, the detective alarm, and the qbit lifecycle with the one-time key absent from audit rows and events.
|
||||
|
||||
### Added (central health)
|
||||
- `authentik.api`, `authentik.api_token`, `authentik.mapper`, and `authentik.token_broker` dependency checks registered by the producer, with safe details and bounded requests through the existing client.
|
||||
- Client-credentials broker probe using the existing token transport; its bearer is discarded.
|
||||
|
||||
### Removed (central health)
|
||||
- The Identity home Connection health card, its direct version/token-expiry probes, and its inline mapper status. A single central component status links to System Health. The configuration drift-report widget is retained.
|
||||
|
||||
### Added (access tokens 6c)
|
||||
- Read-only and app/table scopes, validated at Review and execution and enforced after claims permissions; revoked credentials fail closed. Optional core credential-scope bridge for #1153.
|
||||
- Last-used login activity (90-day window, five-minute cache), creation time and scope summaries; API tokens are explicitly not tracked.
|
||||
|
||||
@@ -14,7 +14,7 @@ Design: [`docs/DESIGN.md`](docs/DESIGN.md) (the approved spec, QRun-IO/qqq#1065)
|
||||
| **Policies** | IAM-style policy documents on groups (`qqq.io/policy`, format in [docs/POLICY-FORMAT.md](docs/POLICY-FORMAT.md)), edited with a matrix of the app's apps, tables, processes and reports and a live JSON preview (Next UI widget `AUTHENTIK_POLICY_EDITOR`). At sign-in they become QQQ permissions through `AuthentikGroupPermissionMapper`, or through the token claim with `AuthentikPolicyClaimMapper`. Parsing fails closed. |
|
||||
| **Safety** | Operators can only grant permissions they hold themselves. No self-edits. Break-glass, the service account and superusers are untouchable, and superuser groups cannot be managed. The last administrator cannot be removed. Granting `authentik.*` needs a second person. Destructive actions need a reason and a typed confirmation. A change made in the Authentik UI after you opened a form is detected, not overwritten. |
|
||||
| **Audit** | Every attempt is a row in `authentik_admin_action` (written before the call), and Authentik's own event log gets a `custom_qqq_admin_action` event naming the human operator. |
|
||||
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, connection), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
|
||||
| **Screens** | A home dashboard (what needs attention, people, groups, recent changes, central health), standard tables with full record screens, and every action as a table or record action with the usual Review and Result steps. Nothing is in the nav except the main tables. |
|
||||
|
||||
Everything is a standard QQQ table, action and widget, so it works in any QQQ UI and over the QQQ API.
|
||||
|
||||
@@ -92,7 +92,7 @@ See also the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-authentik`. No release is on Maven Central yet; CircleCI publishes snapshots from `develop`. |
|
||||
| QQQ version | QQQ 4.1.0-RC.7 (released BOM, no core SNAPSHOT). Java 21. |
|
||||
| QQQ version | QQQ 4.1.0-RC.8 (released BOM, including the core health SPI). Java 21. |
|
||||
| Infrastructure | Authentik, contract-tested against 2026.8.3. The API must be reachable from the app at `baseUrl`. |
|
||||
| Tables | Create `authentik_admin_action`, `authentik_approval_request` and `authentik_token_scope` on your local RDBMS backend (`docs/schema.sql`) before turning write features on. |
|
||||
| Registration | `new AuthentikQBitProducer().withQBitConfig(config).produce(qInstance)`. |
|
||||
@@ -137,6 +137,29 @@ Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` int
|
||||
|
||||
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
|
||||
|
||||
## Health checks
|
||||
|
||||
The producer registers these central checks when Authentik is configured. All use
|
||||
component `authentik`, category `IDENTITY`, and a 10-second timeout. They reuse the
|
||||
API client and drift diagnostics, honor the runner deadline, and record only safe
|
||||
version, expiry-day, and finding-code details.
|
||||
|
||||
| Name | Kind | Interval |
|
||||
| --- | --- | --- |
|
||||
| `authentik.api` | DEPENDENCY | 300 seconds |
|
||||
| `authentik.api_token` | DEPENDENCY | 21600 seconds |
|
||||
| `authentik.mapper` | DEPENDENCY | 21600 seconds |
|
||||
| `authentik.token_broker` | DEPENDENCY | 300 seconds |
|
||||
|
||||
The broker check uses a client-credentials grant and discards the returned bearer;
|
||||
it reports UNKNOWN when broker credentials are not configured. Token metadata must
|
||||
identify one API token; ambiguous tokens or an unavailable expiry report UNKNOWN.
|
||||
The Identity home links its central status to System Health. The configuration
|
||||
`driftReport` widget remains available. Disable checks centrally with
|
||||
`health_check.enabled`; no Authentik table fields are added.
|
||||
|
||||
These registrations use the core health SPI released in QQQ 4.1.0-RC.8; no
|
||||
SNAPSHOT override or enforcer skip is needed.
|
||||
|
||||
## In-app help
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-authentik.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -148,6 +149,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-authentik.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
@@ -247,7 +249,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -66,6 +66,7 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
private String tokenBrokerClientId = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_ID}";
|
||||
private String tokenBrokerClientSecret = "${env.AUTHENTIK_TOKEN_BROKER_CLIENT_SECRET}";
|
||||
private String tokenEndpointUrl = null;
|
||||
private String maintenancePrincipal = "maintenance";
|
||||
private String appPublicUrl = null;
|
||||
private String authentikPublicUrl = null;
|
||||
private String appClientId = null;
|
||||
@@ -1737,6 +1738,29 @@ public class AuthentikQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maintenancePrincipal: the service principal (declared by the
|
||||
** host, docs/SERVICE-IDENTITY.md in qqq) that AuthentikMaintenance runs as.
|
||||
** Its Authentik policy must hold policies.manage and every grant it applies.
|
||||
*******************************************************************************/
|
||||
public String getMaintenancePrincipal()
|
||||
{
|
||||
return (this.maintenancePrincipal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maintenancePrincipal (default maintenance).
|
||||
*******************************************************************************/
|
||||
public AuthentikQBitConfig withMaintenancePrincipal(String maintenancePrincipal)
|
||||
{
|
||||
this.maintenancePrincipal = maintenancePrincipal;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appPublicUrl.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Properties;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.app.AuthentikRelations;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
import com.kingsrook.qbits.authentik.health.AuthentikHealthChecks;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.authentik.metadata.AuthentikHelp;
|
||||
import com.kingsrook.qbits.authentik.processes.AuthentikProcesses;
|
||||
@@ -186,6 +187,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
AuthentikHealthChecks.register(qInstance, qBitConfig);
|
||||
addPossibleValueSources(qInstance);
|
||||
AuthentikRelations.apply(qInstance, qBitConfig);
|
||||
for(QProcessMetaData process : AuthentikProcesses.processes(qBitConfig))
|
||||
|
||||
@@ -63,6 +63,7 @@ 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.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -329,8 +330,8 @@ public class AuthentikAdmin
|
||||
/*******************************************************************************
|
||||
** The operator, resolved once per instance from live Authentik data. When
|
||||
** the operator is not an Authentik user (static or development sessions),
|
||||
** the session's own permissions stand in. Maintenance sessions are local job
|
||||
** identities and never resolved as Authentik users. bootstrapOperatorSubjects additionally
|
||||
** the session's own permissions stand in. Service principal sessions
|
||||
** (maintenance) are never resolved as Authentik users by email. bootstrapOperatorSubjects additionally
|
||||
** hold every authentik.* permission.
|
||||
*******************************************************************************/
|
||||
public Operator operator() throws QException
|
||||
@@ -341,7 +342,7 @@ public class AuthentikAdmin
|
||||
}
|
||||
|
||||
String email = guard.operatorEmail();
|
||||
boolean maintenance = AuthentikMaintenance.isMaintenanceSession(QContext.getQSession());
|
||||
boolean maintenance = ServiceIdentities.isMintedServiceSession(QContext.getQSession());
|
||||
boolean bootstrap = !maintenance && email != null && com.kingsrook.qbits.authentik.security.BootstrapOperators.matchesUniqueUser(config.getResolvedBootstrapSubjects(), util, email);
|
||||
Integer pk = null;
|
||||
Set<String> permissions = new TreeSet<>();
|
||||
|
||||
@@ -21,20 +21,17 @@ import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyGenerator;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParseException;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyParser;
|
||||
import com.kingsrook.qbits.authentik.policy.PolicyPermissions;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -60,13 +57,18 @@ public final class AuthentikMaintenance
|
||||
** plan and execute path. Requires an initialized QContext with the host's
|
||||
** QInstance. The caller's context is restored on success, refusal or failure.
|
||||
**
|
||||
** The operator must start with maintenance: or run-once: and have a nonblank
|
||||
** suffix. Its name and the required reason are recorded verbatim. A dedicated
|
||||
** non-system session holds policies.manage and the document's current grants.
|
||||
** Existing scope, protection, readOnly, feature and stale checks still apply.
|
||||
** Equivalent policies return NO_CHANGE without a write or an audit row.
|
||||
** With policyTwoPersonApproval enabled, a change queues an approval instead
|
||||
** of writing to Authentik; this API never bypasses second-person approval.
|
||||
** Runs as the host's maintenance service principal (config
|
||||
** maintenancePrincipal, default "maintenance"; docs/SERVICE-IDENTITY.md in
|
||||
** qqq): under OAuth2 a real token for its Authentik service account, so its
|
||||
** authority is exactly what the owner granted that account (policies.manage
|
||||
** and every grant the document applies - the subset rule still holds). No
|
||||
** authority is created here. The audit operator is service:{principal};
|
||||
** the job name (maintenance: or run-once:, nonblank suffix) prefixes the
|
||||
** required reason. Existing scope, protection, readOnly, feature and stale
|
||||
** checks still apply. Equivalent policies return NO_CHANGE without a write
|
||||
** or an audit row. With policyTwoPersonApproval enabled, a change queues an
|
||||
** approval instead of writing to Authentik; this API never bypasses
|
||||
** second-person approval.
|
||||
*******************************************************************************/
|
||||
public static AuthentikAdmin.AdminResult applyPolicy(String apiBackendName, String groupName, String policyJson, String operatorName, String reason) throws QException
|
||||
{
|
||||
@@ -87,18 +89,16 @@ public final class AuthentikMaintenance
|
||||
CapturedContext caller = QContext.capture();
|
||||
try
|
||||
{
|
||||
MaintenanceSession session = new MaintenanceSession();
|
||||
session.setUser(new QUser().withIdReference(operatorName).withFullName("Maintenance (" + operatorName + ")"));
|
||||
session.setPermissions(new TreeSet<>(Set.of(IdentityPermission.POLICIES_MANAGE.getSessionPermission())));
|
||||
QContext.init(caller.qInstance(), session);
|
||||
String principal = AuthentikRuntime.forBackend(apiBackendName).getConfig().getMaintenancePrincipal();
|
||||
QContext.init(caller.qInstance(), ServiceIdentities.session(caller.qInstance(), principal));
|
||||
|
||||
AuthentikAdmin admin = AuthentikAdmin.forBackend(apiBackendName);
|
||||
AuthentikRuntime runtime = admin.getUtil().getRuntime();
|
||||
runtime.getGuard().authorize(AdminAction.SAVE_POLICY);
|
||||
Set<String> grants = StringUtils.hasContent(policyJson)
|
||||
? PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog()).permissions() : Set.of();
|
||||
session.getPermissions().addAll(grants.contains(PolicyPermissions.FULL_ACCESS)
|
||||
? PolicyPermissions.allPermissions(caller.qInstance(), runtime.getPermissionCatalog()) : grants);
|
||||
if(StringUtils.hasContent(policyJson))
|
||||
{
|
||||
PolicyParser.parseStrict(policyJson, runtime.getConfig().getPolicyFormat(), runtime.getPolicyCatalog());
|
||||
}
|
||||
|
||||
runtime.invalidateCache();
|
||||
List<JSONObject> groups = admin.getUtil().getAllPages("/core/groups/?name=" + URLEncoder.encode(groupName, StandardCharsets.UTF_8)
|
||||
@@ -108,7 +108,15 @@ public final class AuthentikMaintenance
|
||||
throw (new QUserFacingException("Expected exactly one Authentik group named " + groupName + "."));
|
||||
}
|
||||
JSONObject group = groups.get(0);
|
||||
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), reason)
|
||||
if(isServicePrincipalGroup(group))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a principal never changes a service principal's grants (its own //
|
||||
// or another's): only the owner does, in Identity //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
throw (new QUserFacingException("Service principal groups are changed only by the owner in Identity."));
|
||||
}
|
||||
AdminRequest request = AdminRequest.of(AdminAction.SAVE_POLICY, group.getString("pk"), operatorName + ": " + reason)
|
||||
.with(AuthentikAdmin.POLICY_DOCUMENT, policyJson)
|
||||
.with("expectedFingerprint", AuthentikSafety.groupFingerprint(group));
|
||||
return (admin.execute(request));
|
||||
@@ -125,6 +133,29 @@ public final class AuthentikMaintenance
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A service principal role group: named service:..., or a child of
|
||||
** qqq-service-accounts.
|
||||
*******************************************************************************/
|
||||
static boolean isServicePrincipalGroup(JSONObject group)
|
||||
{
|
||||
if(group.optString("name", "").startsWith("service:"))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
org.json.JSONArray parents = group.optJSONArray("parents_obj");
|
||||
for(int i = 0; parents != null && i < parents.length(); i++)
|
||||
{
|
||||
if("qqq-service-accounts".equals(parents.optJSONObject(i) == null ? null : parents.getJSONObject(i).optString("name")))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical compact full-access document using the host's spec and version.
|
||||
*******************************************************************************/
|
||||
@@ -139,23 +170,4 @@ public final class AuthentikMaintenance
|
||||
throw (new QException("Could not generate the full-access policy.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recognize only sessions created here, never an ordinary user's name prefix.
|
||||
*******************************************************************************/
|
||||
static boolean isMaintenanceSession(QSession session)
|
||||
{
|
||||
return (session instanceof MaintenanceSession);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A local job identity, never an Authentik user or a QSystemUserSession.
|
||||
*******************************************************************************/
|
||||
private static final class MaintenanceSession extends QSession
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,18 @@
|
||||
package com.kingsrook.qbits.authentik.app;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AuthentikLocalTables;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
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.AuthentikGuard;
|
||||
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -56,8 +54,6 @@ public class AuthentikHomeData
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikHomeData.class);
|
||||
|
||||
public static final int TOKEN_WARNING_DAYS = 14;
|
||||
|
||||
private boolean peopleAvailable;
|
||||
private int activePeople;
|
||||
private int inactivePeople;
|
||||
@@ -84,8 +80,10 @@ public class AuthentikHomeData
|
||||
private List<QRecord> recentActions = new ArrayList<>();
|
||||
private List<String> attention = new ArrayList<>();
|
||||
|
||||
private String healthStatus = "UNKNOWN";
|
||||
private Integer healthComponentId;
|
||||
|
||||
private AuthentikQBitConfig config;
|
||||
private String version;
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +102,7 @@ public class AuthentikHomeData
|
||||
data.loadApprovals();
|
||||
data.loadActions();
|
||||
data.loadAttention(util);
|
||||
data.version = util.fetchServerVersion().orElse(null);
|
||||
data.loadHealth();
|
||||
return (data);
|
||||
}
|
||||
|
||||
@@ -240,8 +238,7 @@ public class AuthentikHomeData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Things that need a person: drift findings, blueprint state, tokens about
|
||||
** to expire, bootstrap operators still set.
|
||||
** Configuration review and bootstrap operators still set. Health is central.
|
||||
*******************************************************************************/
|
||||
private void loadAttention(AuthentikActionUtil util)
|
||||
{
|
||||
@@ -275,26 +272,6 @@ public class AuthentikHomeData
|
||||
LOG.info("Identity home: drift report unavailable", logPair("error", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
if(mayAudit)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant soon = Instant.now().plus(TOKEN_WARNING_DAYS, ChronoUnit.DAYS);
|
||||
for(JSONObject token : util.getAllPages("/core/tokens/?user__username=" + java.net.URLEncoder.encode(config.getEffectiveServiceAccountUsername(), java.nio.charset.StandardCharsets.UTF_8), 50))
|
||||
{
|
||||
Instant expires = AuthentikJson.parseInstant(token.optString("expires", null));
|
||||
if(token.optBoolean("expiring", true) && expires != null && expires.isBefore(soon))
|
||||
{
|
||||
attention.add("The qbit's API token (" + token.optString("identifier") + ") expires on "
|
||||
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(java.time.ZoneOffset.UTC).format(expires) + ": rotate it (owner checklist).");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Identity home: token expiry not checked", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,11 +297,51 @@ public class AuthentikHomeData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for version
|
||||
** Read the central rollup using the current session's table permissions.
|
||||
*******************************************************************************/
|
||||
public String getVersion()
|
||||
private void loadHealth()
|
||||
{
|
||||
return (version);
|
||||
if(QContext.getQInstance().getTable("health_component") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
QueryInput query = new QueryInput();
|
||||
query.setTableName("health_component");
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
query.setFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "authentik")).withLimit(1));
|
||||
List<QRecord> records = new QueryAction().execute(query).getRecords();
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
healthStatus = com.kingsrook.qqq.backend.core.health.HealthStatus.valueOf(records.getFirst().getValueString("status")).name();
|
||||
healthComponentId = records.getFirst().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Identity home: central health unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the central component status.
|
||||
*******************************************************************************/
|
||||
public String getHealthStatus()
|
||||
{
|
||||
return (healthStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the central component record link.
|
||||
*******************************************************************************/
|
||||
public Integer getHealthComponentId()
|
||||
{
|
||||
return (healthComponentId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class AuthentikHomeView
|
||||
people(html, data, config);
|
||||
groups(html, data, config);
|
||||
activity(html, data, config);
|
||||
connection(html, data);
|
||||
health(html, data);
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
@@ -204,31 +204,12 @@ public final class AuthentikHomeView
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void connection(StringBuilder html, AuthentikHomeData data)
|
||||
private static void health(StringBuilder html, AuthentikHomeData data)
|
||||
{
|
||||
open(html, "Connection");
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Authentik", data.getConfig().getBaseUrl());
|
||||
row(html, "Version", data.getVersion() == null ? "Not reachable" : data.getVersion());
|
||||
row(html, "Service account", data.getConfig().getEffectiveServiceAccountUsername());
|
||||
row(html, "Changes", Boolean.TRUE.equals(data.getConfig().getReadOnly()) ? "Off (read-only)" : "On, every change is audited here and in Authentik");
|
||||
if(data.getConfig().hasFeature(com.kingsrook.qbits.authentik.AuthentikFeature.POLICIES))
|
||||
{
|
||||
String state;
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.authentik.permissions.AccessComparison.State mapper = com.kingsrook.qbits.authentik.permissions.AccessComparison.state(
|
||||
com.kingsrook.qbits.authentik.AuthentikRuntime.forBackend(data.getConfig().getApiBackendName()),
|
||||
com.kingsrook.qbits.authentik.client.AuthentikActionUtil.forBackend(data.getConfig().getApiBackendName()));
|
||||
state = (mapper.enforced() ? "Enforced. " : "Not enforced. ") + mapper.message();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state = "Unknown (" + e.getMessage() + ")";
|
||||
}
|
||||
row(html, "Policies at sign-in", state);
|
||||
}
|
||||
html.append("</dl>");
|
||||
open(html, "Health");
|
||||
String target = prefix(data.getConfig()) + "/health_component" + (data.getHealthComponentId() == null ? "" : "/" + data.getHealthComponentId());
|
||||
html.append("<p>Authentik: ").append(esc(data.getHealthStatus())).append(" - <a href=\"")
|
||||
.append(esc(target)).append("\">System Health</a></p>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ 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;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpDelete;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
@@ -75,7 +76,6 @@ import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
@@ -116,6 +116,8 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
/** 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 java.time.Instant deadline;
|
||||
|
||||
private EffectivePermissionResolver resolverForCall;
|
||||
|
||||
|
||||
@@ -138,6 +140,94 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound this dedicated health client; ordinary action clients are unchanged.
|
||||
*******************************************************************************/
|
||||
public AuthentikActionUtil withHealthDeadline(java.time.Instant deadline) throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
this.deadline = java.util.Objects.requireNonNull(deadline);
|
||||
remainingMillis();
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remaining health budget; zero must never become an unlimited HTTP timeout.
|
||||
*******************************************************************************/
|
||||
private int remainingMillis() throws QException
|
||||
{
|
||||
long remaining = deadline == null ? Integer.MAX_VALUE : java.time.Duration.between(java.time.Instant.now(), deadline).toMillis();
|
||||
if(remaining <= 0 || (deadline != null && Thread.currentThread().isInterrupted()))
|
||||
{
|
||||
throw (new QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
return ((int) Math.min(Integer.MAX_VALUE, remaining));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured zero means unlimited normally, but health always has a deadline.
|
||||
*******************************************************************************/
|
||||
private static int healthTimeout(int remaining, int configured)
|
||||
{
|
||||
return (configured <= 0 ? remaining : Math.min(remaining, configured));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep retry delays inside the health budget and preserve cancellation.
|
||||
*******************************************************************************/
|
||||
private void retrySleep(long millis) throws QException
|
||||
{
|
||||
if(deadline == null)
|
||||
{
|
||||
SleepUtils.sleep(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
return;
|
||||
}
|
||||
if(millis >= remainingMillis())
|
||||
{
|
||||
throw (new QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
try
|
||||
{
|
||||
Thread.sleep(millis);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Authentik health check interrupted"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the service account API token metadata through the existing paged path.
|
||||
*******************************************************************************/
|
||||
public List<JSONObject> getServiceAccountApiTokens() throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
return (getAllPages("/core/tokens/?intent=api&user__username=" + java.net.URLEncoder.encode(
|
||||
runtime().getConfig().getEffectiveServiceAccountUsername(), StandardCharsets.UTF_8), 50));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Broker credentials use the same secret-safe request loop as token exchange.
|
||||
*******************************************************************************/
|
||||
JSONObject requestBrokerCredentials(HttpPost request) throws QException
|
||||
{
|
||||
runtime().getGuard().checkHealth();
|
||||
return (brokerResponse(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime for the backend this util serves.
|
||||
*******************************************************************************/
|
||||
@@ -463,6 +553,16 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public JSONObject exchangeToken(WriteAuthorization auth, HttpPost request) throws QException
|
||||
{
|
||||
checkAuthorization(auth);
|
||||
return (brokerResponse(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a broker response without exposing its body.
|
||||
*******************************************************************************/
|
||||
private JSONObject brokerResponse(HttpPost request) throws QException
|
||||
{
|
||||
if(!request.getURI().toString().equals(runtime().getConfig().getTokenEndpointUrl()))
|
||||
{
|
||||
throw (new QException("Bad token endpoint"));
|
||||
@@ -826,7 +926,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public GroupGraph groupGraph() throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
if(cache)
|
||||
{
|
||||
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("all").map(m -> m.getResult());
|
||||
@@ -880,7 +980,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
public GroupGraph groupGraphWithMembers() throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
boolean cache = runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
boolean cache = deadline == null && runtime.getConfig().getGroupCacheTtlSeconds() != null && runtime.getConfig().getGroupCacheTtlSeconds() > 0;
|
||||
if(cache)
|
||||
{
|
||||
Optional<GroupGraph> cached = runtime.getGroupGraphCache().getMemoizedResult("members").map(m -> m.getResult());
|
||||
@@ -1302,7 +1402,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
private String getBody(String url, boolean nullOn404) throws QException
|
||||
{
|
||||
AuthentikRuntime runtime = runtime();
|
||||
if(runtime.isCacheEnabled())
|
||||
if(deadline == null && runtime.isCacheEnabled())
|
||||
{
|
||||
Optional<String> cached = runtime.getResponseCache().getMemoizedResult(url).map(m -> m.getResult());
|
||||
if(cached.isPresent())
|
||||
@@ -1322,7 +1422,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
}
|
||||
|
||||
String body = response.getContent();
|
||||
if(runtime.isCacheEnabled() && body != null)
|
||||
if(deadline == null && runtime.isCacheEnabled() && body != null)
|
||||
{
|
||||
runtime.getResponseCache().storeResult(url, body);
|
||||
}
|
||||
@@ -1376,7 +1476,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
while(true)
|
||||
{
|
||||
attempts++;
|
||||
runtime().getThrottle().acquire();
|
||||
remainingMillis();
|
||||
if(deadline == null)
|
||||
{
|
||||
runtime().getThrottle().acquire();
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime().getThrottle().acquire(deadline);
|
||||
int remaining = remainingMillis();
|
||||
request.setConfig(RequestConfig.custom()
|
||||
.setConnectTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
|
||||
.setConnectionRequestTimeout(healthTimeout(remaining, runtime().getConfig().getConnectTimeoutMillis()))
|
||||
.setSocketTimeout(healthTimeout(remaining, runtime().getConfig().getSocketTimeoutMillis())).build());
|
||||
}
|
||||
if(!exchange)
|
||||
{
|
||||
request.setHeader("Authorization", "Bearer " + token);
|
||||
@@ -1392,6 +1505,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
try
|
||||
{
|
||||
response = send(request);
|
||||
remainingMillis();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -1399,7 +1513,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
LOG.warn("Authentik API call failed", logPair("method", request.getMethod()), logPair("path", path), logPair("error", message));
|
||||
if(attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
|
||||
{
|
||||
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(backoffMs);
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
@@ -1412,17 +1526,20 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
logOutboundCall(request, path, response, token);
|
||||
}
|
||||
checkVersionOnce(status);
|
||||
if(deadline == null)
|
||||
{
|
||||
checkVersionOnce(status);
|
||||
}
|
||||
|
||||
if(status == 429 && attempts <= MAX_RETRIES)
|
||||
{
|
||||
SleepUtils.sleep(retryAfterMillis(response, backoffMs), java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(retryAfterMillis(response, backoffMs));
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
if(status >= 500 && attempts < MAX_RETRIES && "GET".equals(request.getMethod()))
|
||||
{
|
||||
SleepUtils.sleep(backoffMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
retrySleep(backoffMs);
|
||||
backoffMs *= 2;
|
||||
continue;
|
||||
}
|
||||
@@ -1446,7 +1563,7 @@ public class AuthentikActionUtil extends BaseAPIActionUtil
|
||||
{
|
||||
// Never echo an untrusted broker error body.
|
||||
}
|
||||
throw (new QUserFacingException("Authentik refused to act as you (" + error + ")"));
|
||||
throw (new BrokerRefusedException(error));
|
||||
}
|
||||
throw (mapError(request, path, status, response.getContent(), token));
|
||||
}
|
||||
|
||||
@@ -58,4 +58,44 @@ public class AuthentikTokenBroker
|
||||
}
|
||||
return (bearer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prove the broker's client credentials with the grant it actually uses
|
||||
** (token exchange), without a subject token, so nothing is issued and no
|
||||
** user data is sent. Authentik checks the grant type, then the client
|
||||
** secret, then the subject: invalid_request (missing subject) means both
|
||||
** passed; invalid_client means a bad secret; invalid_grant means the
|
||||
** provider does not allow token exchange.
|
||||
*******************************************************************************/
|
||||
public void probeCredentials(AuthentikActionUtil util) throws QException
|
||||
{
|
||||
AuthentikQBitConfig config = util.getRuntime().getConfig();
|
||||
HttpPost request = new HttpPost(config.getTokenEndpointUrl());
|
||||
request.setEntity(new UrlEncodedFormEntity(List.of(
|
||||
new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
|
||||
new BasicNameValuePair("client_id", config.getTokenBrokerClientId()),
|
||||
new BasicNameValuePair("client_secret", config.getTokenBrokerClientSecret())), StandardCharsets.UTF_8));
|
||||
String code;
|
||||
try
|
||||
{
|
||||
util.requestBrokerCredentials(request);
|
||||
code = "issued";
|
||||
}
|
||||
catch(BrokerRefusedException e)
|
||||
{
|
||||
code = e.getErrorCode();
|
||||
}
|
||||
switch(code)
|
||||
{
|
||||
case "invalid_request" ->
|
||||
{
|
||||
return;
|
||||
}
|
||||
case "invalid_client" -> throw (new QUserFacingException("Authentik rejected the token broker's client credentials"));
|
||||
case "invalid_grant", "unsupported_grant_type" -> throw (new QUserFacingException("The token broker's Authentik provider does not allow token exchange"));
|
||||
default -> throw (new QUserFacingException("Authentik answered the token broker probe unexpectedly (" + code + ")"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentik's token endpoint refused a broker request. Carries only the
|
||||
** standard OAuth2 error code (never the untrusted response body).
|
||||
*******************************************************************************/
|
||||
public class BrokerRefusedException extends QUserFacingException
|
||||
{
|
||||
private final String errorCode;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public BrokerRefusedException(String errorCode)
|
||||
{
|
||||
super("Authentik refused to act as you (" + errorCode + ")");
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The OAuth2 error code, or exchange_failed when it was not a standard one.
|
||||
*******************************************************************************/
|
||||
public String getErrorCode()
|
||||
{
|
||||
return (errorCode);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,30 @@ public class TokenBucketThrottle
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reserve a token only when the health deadline permits the throttle delay.
|
||||
*******************************************************************************/
|
||||
public void acquire(java.time.Instant deadline) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
long waitNanos;
|
||||
synchronized(this)
|
||||
{
|
||||
refill();
|
||||
waitNanos = (long) Math.ceil(Math.max(0, 1 - tokens) / tokensPerNano);
|
||||
if(Thread.currentThread().isInterrupted() || waitNanos >= java.time.Duration.between(java.time.Instant.now(), deadline).toNanos())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("Authentik health deadline exceeded"));
|
||||
}
|
||||
tokens -= 1;
|
||||
}
|
||||
if(waitNanos > 0)
|
||||
{
|
||||
nanoSleeper.accept(waitNanos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tokens currently available (may be negative while callers are waiting).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
@@ -32,9 +33,9 @@ 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;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -61,6 +62,9 @@ public class AuthentikDriftReport
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AuthentikDriftReport.class);
|
||||
|
||||
private static final Pattern SAFE_PERMISSION = Pattern.compile("[A-Za-z0-9_.*-]{1,120}");
|
||||
private static final Pattern SAFE_ATTRIBUTE = Pattern.compile("[A-Za-z0-9_./-]{1,60}");
|
||||
|
||||
private final AuthentikActionUtil client;
|
||||
private final AuthentikQBitConfig config;
|
||||
private final AuthentikGuard guard;
|
||||
@@ -98,6 +102,19 @@ public class AuthentikDriftReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse only mapper attributes and service-account lockout diagnostics.
|
||||
*******************************************************************************/
|
||||
public List<DriftFinding> runMapperChecks()
|
||||
{
|
||||
List<DriftFinding> findings = new ArrayList<>();
|
||||
runCheck(findings, "group attributes", this::checkGroupAttributes);
|
||||
runCheck(findings, "service-account activity", this::checkServiceAccountActivity);
|
||||
return (findings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A check.
|
||||
*******************************************************************************/
|
||||
@@ -360,6 +377,19 @@ public class AuthentikDriftReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The attribute name of a GroupGraph malformed entry ("key: detail"), never
|
||||
** the detail, which can echo whatever a person typed.
|
||||
*******************************************************************************/
|
||||
static String attributeOf(String malformedEntry)
|
||||
{
|
||||
int colon = malformedEntry == null ? -1 : malformedEntry.indexOf(": ");
|
||||
String key = colon < 0 ? "" : malformedEntry.substring(0, colon);
|
||||
return (SAFE_ATTRIBUTE.matcher(key).matches() ? key : "attribute");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 6
|
||||
*******************************************************************************/
|
||||
@@ -375,14 +405,16 @@ public class AuthentikDriftReport
|
||||
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."));
|
||||
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.")
|
||||
.withReason("malformed " + attributeOf(bad)));
|
||||
}
|
||||
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)."));
|
||||
findings.add(DriftFinding.warning("group attributes", node.name(), "qqq.io/permissions entry \"" + permission + "\" grants nothing in this app (unknown permission or empty wildcard; a name the app checks only in code, not in its metadata, is not in the catalog).")
|
||||
.withReason("grants nothing: " + (SAFE_PERMISSION.matcher(permission).matches() ? permission : "(unprintable name)")));
|
||||
}
|
||||
}
|
||||
for(String app : node.appAccess())
|
||||
@@ -396,7 +428,8 @@ public class AuthentikDriftReport
|
||||
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."));
|
||||
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.")
|
||||
.withReason("superuser group carries qqq.io/permissions"));
|
||||
}
|
||||
}
|
||||
if(issues == 0)
|
||||
|
||||
@@ -25,10 +25,33 @@ package com.kingsrook.qbits.authentik.drift;
|
||||
** @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
|
||||
** @param reason a short, safe summary for health outcomes (no attribute
|
||||
** values a person typed beyond validated permission names);
|
||||
** null when the finding has none
|
||||
*******************************************************************************/
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message)
|
||||
public record DriftFinding(Severity severity, String check, String subject, String message, String reason)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A finding without a health reason.
|
||||
*******************************************************************************/
|
||||
public DriftFinding(Severity severity, String check, String subject, String message)
|
||||
{
|
||||
this(severity, check, subject, message, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This finding with a short, safe reason for health outcomes.
|
||||
*******************************************************************************/
|
||||
public DriftFinding withReason(String reason)
|
||||
{
|
||||
return (new DriftFinding(severity, check, subject, message, reason));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Severity, most severe first.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
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.AuthentikTokenBroker;
|
||||
import com.kingsrook.qbits.authentik.drift.AuthentikDriftReport;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central identity checks reuse the qbit client and configuration diagnostics.
|
||||
*******************************************************************************/
|
||||
public final class AuthentikHealthChecks
|
||||
{
|
||||
private static final String GROUP_ATTRIBUTES = "group attributes";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AuthentikHealthChecks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Called only after the producer has validated and registered the runtime.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, AuthentikQBitConfig config)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
for(String name : List.of("api", "api_token", "mapper", "token_broker"))
|
||||
{
|
||||
registry.withCheck(new HealthCheckMetaData().withName("authentik." + name).withLabel("Authentik " + name)
|
||||
.withComponent("authentik").withCategory(HealthCategory.IDENTITY).withKind(HealthCheckKind.DEPENDENCY)
|
||||
.withIntervalSeconds(name.equals("api") || name.equals("token_broker") ? 300 : 21600)
|
||||
.withTimeoutMs(10000).withCheckInstance(input ->
|
||||
{
|
||||
try
|
||||
{
|
||||
AuthentikActionUtil util = AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(input.getDeadline());
|
||||
HealthCheckOutcome outcome;
|
||||
switch(name)
|
||||
{
|
||||
case "api" -> outcome = api(util, config);
|
||||
case "api_token" -> outcome = apiToken(util, Instant.now());
|
||||
case "mapper" -> outcome = mapper(util, config);
|
||||
default -> outcome = tokenBroker(util, config);
|
||||
}
|
||||
return (Instant.now().isBefore(input.getDeadline()) && !Thread.currentThread().isInterrupted()
|
||||
? outcome : HealthCheckOutcome.down("Authentik health deadline exceeded"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Never copy exception messages, URLs or credential values into outcomes.
|
||||
return (HealthCheckOutcome.down("Authentik health check failed"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version compatibility follows the existing drift report's prefix rule.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome api(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
{
|
||||
String version = util.fetchServerVersion().orElse("");
|
||||
if(!version.matches("[0-9]{1,4}(\\.[0-9]{1,4}){1,3}"))
|
||||
{
|
||||
return (HealthCheckOutcome.down("Authentik server version unavailable"));
|
||||
}
|
||||
String expected = config.getExpectedServerVersion();
|
||||
boolean matches = !StringUtils.hasContent(expected) || version.equals(expected) || version.startsWith(expected + ".");
|
||||
return ((matches ? HealthCheckOutcome.up("Authentik API reachable") : HealthCheckOutcome.degraded("Authentik server version differs"))
|
||||
.withDetails(new HealthDetails().withValue("serverVersion", version)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service account should have one API token; never read its key.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome apiToken(AuthentikActionUtil util, Instant now) throws QException
|
||||
{
|
||||
List<JSONObject> tokens = util.getServiceAccountApiTokens();
|
||||
if(tokens.isEmpty())
|
||||
{
|
||||
return (HealthCheckOutcome.down("API token missing"));
|
||||
}
|
||||
if(tokens.size() != 1)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token cannot be identified uniquely"));
|
||||
}
|
||||
JSONObject token = tokens.getFirst();
|
||||
if(!token.optBoolean("expiring", true))
|
||||
{
|
||||
return (HealthCheckOutcome.up("API token present without expiry"));
|
||||
}
|
||||
Instant expires;
|
||||
try
|
||||
{
|
||||
expires = AuthentikJson.parseInstant(token.optString("expires", null));
|
||||
}
|
||||
catch(java.time.format.DateTimeParseException e)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
|
||||
}
|
||||
if(expires == null)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("API token expiry unavailable"));
|
||||
}
|
||||
HealthCheckOutcome outcome = !expires.isAfter(now) ? HealthCheckOutcome.down("API token expired")
|
||||
: expires.isBefore(now.plus(14, ChronoUnit.DAYS)) ? HealthCheckOutcome.degraded("API token expires within 14 days")
|
||||
: HealthCheckOutcome.up("API token present");
|
||||
return (outcome.withDetails(new HealthDetails().withValue("daysToExpiry", ChronoUnit.DAYS.between(now, expires))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Findings carry fixed codes, plus the flagged group names and each group
|
||||
** finding's safe reason (DriftFinding.reason), so a degraded mapper says
|
||||
** what to fix. Service-account activity subjects (people) stay private.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome mapper(AuthentikActionUtil util, AuthentikQBitConfig config)
|
||||
{
|
||||
return (mapperOutcome(new AuthentikDriftReport(util, config).runMapperChecks()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mapper outcome for a set of drift findings.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome mapperOutcome(List<DriftFinding> findings)
|
||||
{
|
||||
HealthStatus status = HealthStatus.UP;
|
||||
TreeSet<String> codes = new TreeSet<>();
|
||||
TreeMap<String, TreeMap<String, TreeSet<String>>> byGroup = new TreeMap<>();
|
||||
TreeSet<String> other = new TreeSet<>();
|
||||
for(DriftFinding finding : findings)
|
||||
{
|
||||
if(finding.severity() != DriftFinding.Severity.ERROR && finding.severity() != DriftFinding.Severity.WARNING)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean groupCheck = GROUP_ATTRIBUTES.equals(finding.check());
|
||||
codes.add((groupCheck ? "group_attributes" : "service_account_activity") + "." + finding.severity().name().toLowerCase(Locale.ROOT));
|
||||
status = finding.severity() == DriftFinding.Severity.ERROR || status == HealthStatus.DOWN ? HealthStatus.DOWN : HealthStatus.DEGRADED;
|
||||
if(groupCheck && finding.reason() != null)
|
||||
{
|
||||
String reason = finding.reason();
|
||||
int colon = reason.indexOf(": ");
|
||||
TreeSet<String> values = byGroup.computeIfAbsent(safeName(finding.subject()), k -> new TreeMap<>())
|
||||
.computeIfAbsent(colon < 0 ? reason : reason.substring(0, colon), k -> new TreeSet<>());
|
||||
if(colon >= 0)
|
||||
{
|
||||
values.add(reason.substring(colon + 2));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
other.add(groupCheck ? "group attributes check could not run" : "service-account activity (see the identity drift report)");
|
||||
}
|
||||
}
|
||||
if(status == HealthStatus.UP)
|
||||
{
|
||||
return (HealthCheckOutcome.up("Mapper checks passed").withDetails(new HealthDetails().withValue("findingCodes", "")));
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
byGroup.forEach((group, reasons) ->
|
||||
{
|
||||
List<String> texts = new ArrayList<>();
|
||||
reasons.forEach((label, values) -> texts.add(values.isEmpty() ? label : label + ": " + String.join(", ", values)));
|
||||
parts.add(group + " (" + String.join("; ", texts) + ")");
|
||||
});
|
||||
parts.addAll(other);
|
||||
String summary = String.join("; ", parts);
|
||||
return (new HealthCheckOutcome().withStatus(status).withMessage(truncate("Mapper drift: " + summary, 500))
|
||||
.withDetails(new HealthDetails()
|
||||
.withValue("findingCodes", String.join(",", codes))
|
||||
.withValue("flaggedGroups", truncate(String.join(", ", byGroup.keySet()), 500))
|
||||
.withValue("summary", truncate(summary, 500))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group name fit for a health message: printable and bounded.
|
||||
*******************************************************************************/
|
||||
private static String safeName(String name)
|
||||
{
|
||||
String printable = name == null ? "" : name.replaceAll("\\p{Cntrl}", "").trim();
|
||||
return (printable.isEmpty() ? "(unnamed group)" : truncate(printable, 100));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cut a text to a length, marking the cut.
|
||||
*******************************************************************************/
|
||||
private static String truncate(String text, int max)
|
||||
{
|
||||
return (text.length() <= max ? text : text.substring(0, max - 3) + "...");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticate through the existing broker transport, retaining no bearer.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome tokenBroker(AuthentikActionUtil util, AuthentikQBitConfig config) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(config.getTokenBrokerClientId()) || !StringUtils.hasContent(config.getTokenBrokerClientSecret()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("not configured"));
|
||||
}
|
||||
new AuthentikTokenBroker().probeCredentials(util);
|
||||
return (HealthCheckOutcome.up("Token broker authenticated"));
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ package com.kingsrook.qbits.authentik.security;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.admin.AdminAction;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikJson;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTableSpec;
|
||||
import com.kingsrook.qbits.authentik.client.AuthentikTables;
|
||||
@@ -65,6 +65,21 @@ public class AuthentikGuard
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Health probes expose only safe outcomes, including to the central runner.
|
||||
*******************************************************************************/
|
||||
public void checkHealth() throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
requireUser(session);
|
||||
if(!has(session, IdentityPermission.ADMIN) && (session.getPermissions() == null || !session.getPermissions().contains("health.read")))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Health read permission is required."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check that the current session may read a table.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
bundle: qbit-authentik
|
||||
version: 0.1.0-SNAPSHOT
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:authentikAdmin;slot:help
|
||||
|
||||
@@ -33,6 +33,9 @@ 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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,13 +44,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host maintenance uses the audited Save Policy path with a local job identity.
|
||||
** Host maintenance uses the audited Save Policy path as the maintenance service
|
||||
** principal (a local principal here: the test instance uses MOCK auth).
|
||||
*******************************************************************************/
|
||||
class AuthentikMaintenanceTest
|
||||
{
|
||||
private static final String GROUP = "kof-website-analysts";
|
||||
private static final String OPERATOR = "run-once:KOF22_RUN_ONCE";
|
||||
private static final String REASON = " owner request 2026-10-14: full-access policy for kof22-admins ";
|
||||
private static final String PRINCIPAL_ID = "service:maintenance";
|
||||
private static final String AUDIT_REASON = OPERATOR + ": " + REASON;
|
||||
|
||||
private AdminTestSupport t;
|
||||
private String fullAccess;
|
||||
@@ -62,13 +68,17 @@ class AuthentikMaintenanceTest
|
||||
{
|
||||
t = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.GROUPS, AuthentikFeature.POLICIES));
|
||||
fullAccess = AuthentikMaintenance.fullAccessDocument(t.config);
|
||||
Set<String> all = new java.util.TreeSet<>(PolicyPermissions.allPermissions(t.qInstance, t.admin().getUtil().getRuntime().getPermissionCatalog()));
|
||||
all.add(IdentityPermission.POLICIES_MANAGE.getSessionPermission());
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("maintenance").withLocalPermissions(all.toArray(new String[0])));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A system job gets a dedicated, fully expanded session. The audit precedes
|
||||
** PATCH, the event identifies the job, and an equivalent document is a no-op.
|
||||
** A system job runs as the maintenance principal (its own grants, nothing
|
||||
** minted here). The audit precedes PATCH, the operator is the principal,
|
||||
** the reason names the job, and an equivalent document is a no-op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesFullAccessAuditedAndIdempotently() throws Exception
|
||||
@@ -84,8 +94,8 @@ class AuthentikMaintenanceTest
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
assertThat(session).isNotInstanceOf(QSystemUserSession.class);
|
||||
assertThat(session.getUser().getIdReference()).isEqualTo(OPERATOR);
|
||||
assertThat(session.getUser().getFullName()).isEqualTo("Maintenance (" + OPERATOR + ")");
|
||||
assertThat(ServiceIdentities.isMintedServiceSession(session)).isTrue();
|
||||
assertThat(session.getUser().getIdReference()).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(session.getPermissions()).containsAll(all).doesNotContain(PolicyPermissions.FULL_ACCESS);
|
||||
assertThat(QContext.getActionStack()).isNotSameAs(caller.actionStack());
|
||||
assertThat(QContext.getAuthorizedActionScopeDepth()).isZero();
|
||||
@@ -112,15 +122,15 @@ class AuthentikMaintenanceTest
|
||||
assertThat(t.authentik.groupByName(GROUP).getJSONObject("attributes").getString(t.config.getPolicyAttribute())).isEqualTo(fullAccess);
|
||||
assertThat(rows(AuthentikLocalTables.ADMIN_ACTION)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("operator")).isEqualTo(OPERATOR);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(REASON);
|
||||
assertThat(row.getValueString("operator")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
|
||||
assertThat(row.getValueString("status")).isEqualTo("SUCCEEDED");
|
||||
});
|
||||
assertThat(t.authentik.events).singleElement().satisfies(event ->
|
||||
{
|
||||
assertThat(event.getString("action")).isEqualTo("custom_qqq_admin_action");
|
||||
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(OPERATOR);
|
||||
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(REASON);
|
||||
assertThat(event.getJSONObject("context").getString("operator")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(event.getJSONObject("context").getString("reason")).isEqualTo(AUDIT_REASON);
|
||||
});
|
||||
assertThat(result.eventPk()).isNotBlank();
|
||||
int writes = t.writes().size();
|
||||
@@ -134,12 +144,16 @@ class AuthentikMaintenanceTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary policies need only their grants and policies.manage, and honor
|
||||
** Ordinary policies need only their grants and policies.manage (held by the
|
||||
** principal, not added here), and honor
|
||||
** the configured spec/version. The maintenance: prefix is accepted too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesNamedPolicyWithLimitedSession() throws Exception
|
||||
{
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("policy-maintenance")
|
||||
.withLocalPermissions("cms_page.read", IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
|
||||
t.config.withMaintenancePrincipal("policy-maintenance");
|
||||
t.config.withPolicySpec("host-iam");
|
||||
String document = AuthentikMaintenance.fullAccessDocument(t.config).replace("\"Action\":[\"*\"]", "\"Action\":[\"read\"]")
|
||||
.replace("\"Resource\":[\"*\"]", "\"Resource\":[\"cms_page\"]");
|
||||
@@ -245,8 +259,8 @@ class AuthentikMaintenanceTest
|
||||
assertThat(row.getValueString("status")).isEqualTo("PENDING_APPROVAL"));
|
||||
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("requester")).isEqualTo(OPERATOR);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(REASON);
|
||||
assertThat(row.getValueString("requester")).isEqualTo(PRINCIPAL_ID);
|
||||
assertThat(row.getValueString("reason")).isEqualTo(AUDIT_REASON);
|
||||
});
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
@@ -314,6 +328,52 @@ class AuthentikMaintenanceTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a declared principal, or with one that lacks the grants, nothing
|
||||
** is written: maintenance never creates authority.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void needsAPrincipalHoldingTheGrants() throws Exception
|
||||
{
|
||||
CapturedContext caller = QContext.capture();
|
||||
t.config.withMaintenancePrincipal("undeclared");
|
||||
assertThatThrownBy(() -> apply(GROUP, fullAccess)).hasMessageContaining("not declared");
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
|
||||
ServiceIdentitiesMetaData.declare(t.qInstance, new QServicePrincipalMetaData().withName("weak")
|
||||
.withLocalPermissions(IdentityPermission.POLICIES_MANAGE.getSessionPermission()));
|
||||
t.config.withMaintenancePrincipal("weak");
|
||||
AuthentikAdmin.AdminResult result;
|
||||
try
|
||||
{
|
||||
result = apply(GROUP, fullAccess);
|
||||
assertThat(result.ok()).isFalse();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
assertThat(e).isNotNull();
|
||||
}
|
||||
assertThat(t.writes()).isEmpty();
|
||||
assertThat(QContext.capture()).isEqualTo(caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maintenance never changes a service principal's role group.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesServicePrincipalGroups() throws Exception
|
||||
{
|
||||
t.authentik.group("service:kof22-scheduler", false, List.of(), List.of());
|
||||
assertThatThrownBy(() -> apply("service:kof22-scheduler", fullAccess)).hasMessageContaining("Service principal groups");
|
||||
assertThat(AuthentikMaintenance.isServicePrincipalGroup(new JSONObject().put("name", "x")
|
||||
.put("parents_obj", new org.json.JSONArray().put(new JSONObject().put("name", "qqq-service-accounts"))))).isTrue();
|
||||
assertThat(t.writes()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply using the production public API.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -175,7 +175,7 @@ class PolicyCutoverTest
|
||||
assertThat(PolicyCutoverWidgets.bannerHtml(new AccessComparison.State(AccessComparison.Status.ACTIVE, "ok <b>", java.time.Instant.now()))).contains("Enforced.").contains("ok <b>");
|
||||
|
||||
String home = com.kingsrook.qbits.authentik.app.AuthentikHomeView.render(com.kingsrook.qbits.authentik.app.AuthentikHomeData.load(t.config));
|
||||
assertThat(home).contains("Policies at sign-in").contains("mapper off");
|
||||
assertThat(home).contains("System Health").doesNotContain("Policies at sign-in");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class AuthentikAppTest
|
||||
RenderWidgetOutput output = ((AbstractWidgetRenderer) new AuthentikHomeWidget.Renderer()).render(input);
|
||||
String html = ((RawHTML) output.getWidgetData()).getHtml();
|
||||
|
||||
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Connection");
|
||||
assertThat(html).contains("Needs attention", "People", "Groups and permissions", "Recent changes", "Health", "System Health");
|
||||
assertThat(html).contains("Active</dt><dd style=\"margin:0;\">4<");
|
||||
assertThat(html).contains("Inactive</dt><dd style=\"margin:0;\">1<");
|
||||
assertThat(html).contains("Service accounts</dt><dd style=\"margin:0;\">2<");
|
||||
@@ -154,6 +154,28 @@ class AuthentikAppTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home reads the persisted central component and links directly to its record.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void centralHealthRollup() throws Exception
|
||||
{
|
||||
AdminTestSupport t = AdminTestSupport.create();
|
||||
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(t.qInstance, AdminTestSupport.LOCAL, table -> { });
|
||||
com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore.getInstance().insert(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput().withTableName("health_component")
|
||||
.withRecord(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", 7).withValue("name", "authentik").withValue("status", "DEGRADED")), true);
|
||||
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of("health.read"));
|
||||
AuthentikHomeData data = AuthentikHomeData.load(t.config);
|
||||
assertThat(data.getHealthStatus()).isEqualTo("DEGRADED");
|
||||
assertThat(AuthentikHomeView.render(data)).contains("Authentik: DEGRADED", "href=\"/app/health_component/7\"")
|
||||
.doesNotContain("Policies at sign-in", "Version</dt>");
|
||||
AuthentikTestSupport.loginWithPermissions(t.qInstance, java.util.Set.of());
|
||||
assertThat(AuthentikHomeData.load(t.config).getHealthStatus()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing but AuthentikAdmin may send a change to Authentik: the util's
|
||||
** write method is only called from there (the guard's authorization is the
|
||||
|
||||
@@ -186,4 +186,20 @@ class AuthentikDriftReportTest
|
||||
assertThatThrownBy(() -> new AuthentikDriftReportWidgetRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A malformed entry's health reason keeps the attribute name, never the
|
||||
** value a person typed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void malformedReasonKeepsOnlyTheAttribute()
|
||||
{
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/permissions: {\"x\":\"typed value\"}")).isEqualTo("qqq.io/permissions");
|
||||
assertThat(AuthentikDriftReport.attributeOf("qqq.io/policy: line 1: bad")).isEqualTo("qqq.io/policy");
|
||||
assertThat(AuthentikDriftReport.attributeOf("no separator")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf("bad key with spaces: x")).isEqualTo("attribute");
|
||||
assertThat(AuthentikDriftReport.attributeOf(null)).isEqualTo("attribute");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
||||
import com.kingsrook.qbits.authentik.AuthentikQBitProducer;
|
||||
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.TokenBucketThrottle;
|
||||
import com.kingsrook.qbits.authentik.drift.DriftFinding;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.QHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.client.methods.HttpRequestBase;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
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 org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Offline probes run under the central runner's health-only permissions.
|
||||
*******************************************************************************/
|
||||
class AuthentikHealthChecksTest
|
||||
{
|
||||
private static final String SECRET = "S3NT1NEL-secret";
|
||||
private AuthentikQBitConfig config;
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the existing send seam with poisoned credentials.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
FixtureActionUtil.reset();
|
||||
config = AuthentikTestSupport.unitConfig().withApiToken(SECRET).withTokenBrokerClientId(SECRET)
|
||||
.withTokenBrokerClientSecret(SECRET).withGroupCacheTtlSeconds(0);
|
||||
instance = AuthentikTestSupport.produce(config);
|
||||
AuthentikTestSupport.useActionUtil(instance, config, HealthActionUtil.class);
|
||||
AuthentikTestSupport.loginWithPermissions(instance, Set.of("health.read", "health.write"));
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, FixtureActionUtil.EMPTY_PAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not leak static fixture state or thread context between tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
FixtureActionUtil.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata is registered by production, with no probes during registration.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws QException
|
||||
{
|
||||
var checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
|
||||
assertThat(checks).extracting(c -> c.getName()).containsExactly("authentik.api", "authentik.api_token", "authentik.mapper", "authentik.token_broker");
|
||||
assertThat(checks).allSatisfy(c ->
|
||||
{
|
||||
assertThat(c.getComponent()).isEqualTo("authentik");
|
||||
assertThat(c.getCategory()).isEqualTo(HealthCategory.IDENTITY);
|
||||
assertThat(c.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(c.getIntervalSeconds()).isEqualTo(c.getName().equals("authentik.api") || c.getName().equals("authentik.token_broker") ? 300 : 21600);
|
||||
assertThat(c.getTimeoutMs()).isEqualTo(10000);
|
||||
});
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
assertThat(instance.getWidget("authentikDriftReport")).isNotNull();
|
||||
QInstance empty = new QInstance();
|
||||
assertThat(new AuthentikQBitProducer().isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> new AuthentikQBitProducer().produce(empty)).isInstanceOf(QException.class);
|
||||
assertThat(HealthChecksMetaData.ofOrWithNew(empty).getChecks()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version responses are allowlisted before entering details.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiVersions() throws QException
|
||||
{
|
||||
respond(new JSONObject().put("version_current", "2026.8.3"));
|
||||
assertThat(run("api").getDetails().getValues()).containsEntry("serverVersion", "2026.8.3");
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
config.withExpectedServerVersion("2025.10");
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
config.withExpectedServerVersion(null);
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
respond(new JSONObject().put("version_current", SECRET));
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertClean(run("api"));
|
||||
respond(new JSONObject());
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Present, approaching, expired, missing and ambiguous token metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void apiTokenStates() throws QException
|
||||
{
|
||||
JSONObject token = new JSONObject().put("intent", "api").put("identifier", SECRET).put("expiring", true);
|
||||
token.put("expires", Instant.now().plusSeconds(30 * 86400).toString());
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
token.put("expires", Instant.now().plusSeconds(13 * 86400).toString());
|
||||
page(token);
|
||||
var outcome = run("api_token");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getDetails().getValues()).containsOnlyKeys("daysToExpiry");
|
||||
token.put("expires", Instant.now().minusSeconds(60).toString());
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
token.put("expiring", false);
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
token.put("expiring", true).put("expires", SECRET);
|
||||
page(token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
page(token, token);
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
page();
|
||||
assertThat(run("api_token").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request ->
|
||||
{
|
||||
assertThat(request.getURI().getPath()).endsWith("/core/tokens/");
|
||||
assertThat(request.getURI().getQuery()).contains("intent=api", "user__username=");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the two relevant drift checks run. Group findings name the group and
|
||||
** a safe reason; typed attribute values and people (service-account
|
||||
** activity subjects) stay private.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperStates() throws QException
|
||||
{
|
||||
assertThat(run("mapper").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// a group with no qqq.io/* attributes (kof22-admins on its static floor) is //
|
||||
// not drift //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
JSONObject floorOnly = new JSONObject().put("pk", "g0").put("name", "kof22-admins").put("attributes", new JSONObject());
|
||||
groups(floorOnly);
|
||||
var outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper checks passed");
|
||||
|
||||
JSONObject unknown = new JSONObject().put("pk", "g1").put("name", "file-gateway-admins")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("file_gateway.view").put("file_gateway.admin")));
|
||||
JSONObject malformed = new JSONObject().put("pk", "g2").put("name", "editors")
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put(new JSONObject().put("x", SECRET))));
|
||||
JSONObject superuser = new JSONObject().put("pk", "g3").put("name", "authentik Admins").put("is_superuser", true)
|
||||
.put("attributes", new JSONObject().put("qqq.io/permissions", new JSONArray().put("authentik.users.read")));
|
||||
groups(floorOnly, unknown, malformed, superuser);
|
||||
outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: authentik Admins (superuser group carries qqq.io/permissions); "
|
||||
+ "editors (malformed qqq.io/permissions); file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view)");
|
||||
assertThat(outcome.getDetails().getValues())
|
||||
.containsEntry("findingCodes", "group_attributes.warning")
|
||||
.containsEntry("flaggedGroups", "authentik Admins, editors, file-gateway-admins")
|
||||
.containsEntry("summary", outcome.getMessage().substring("Mapper drift: ".length()));
|
||||
assertClean(outcome);
|
||||
|
||||
groups(unknown);
|
||||
page(new JSONObject().put("action", "model_deleted").put("context", new JSONObject()
|
||||
.put("model", new JSONObject().put("model_name", "user").put("pk", "1").put("name", SECRET))));
|
||||
outcome = run("mapper");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: file-gateway-admins (grants nothing: file_gateway.admin, file_gateway.view); "
|
||||
+ "service-account activity (see the identity drift report)");
|
||||
assertClean(outcome);
|
||||
assertThat(FixtureActionUtil.REQUESTS).allSatisfy(request -> assertThat(request.getURI().getPath())
|
||||
.doesNotContain("/admin/version/", "/core/tokens/", "/managed/blueprints/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group check that cannot run, unprintable names and long summaries stay
|
||||
** readable and within the health limits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mapperOutcomeEdges()
|
||||
{
|
||||
var outcome = AuthentikHealthChecks.mapperOutcome(List.of(
|
||||
DriftFinding.error("group attributes", "-", "Check could not run: " + SECRET),
|
||||
DriftFinding.info("group attributes", "x", "info only").withReason("ignored"),
|
||||
DriftFinding.warning("group attributes", "bad\u0007name", "m").withReason("grants nothing: (unprintable name)"),
|
||||
DriftFinding.warning("group attributes", "", "m").withReason("malformed attribute")));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Mapper drift: (unnamed group) (malformed attribute); badname (grants nothing: (unprintable name)); "
|
||||
+ "group attributes check could not run");
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("findingCodes", "group_attributes.error,group_attributes.warning");
|
||||
assertClean(outcome);
|
||||
|
||||
List<DriftFinding> many = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 60; i++)
|
||||
{
|
||||
many.add(DriftFinding.warning("group attributes", "group-number-" + i, "m").withReason("grants nothing: some.long.permission.name." + i));
|
||||
}
|
||||
outcome = AuthentikHealthChecks.mapperOutcome(many);
|
||||
assertThat(outcome.getMessage()).hasSize(500).endsWith("...");
|
||||
assertThat(outcome.getDetails().toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8).length).isLessThanOrEqualTo(4096);
|
||||
assertThat(outcome.getDetails().getValues()).containsKeys("findingCodes", "flaggedGroups", "summary");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve these groups as the group graph.
|
||||
*******************************************************************************/
|
||||
private static void groups(JSONObject... groups)
|
||||
{
|
||||
JSONArray results = new JSONArray();
|
||||
for(JSONObject group : groups)
|
||||
{
|
||||
results.put(group);
|
||||
}
|
||||
FixtureActionUtil.groupGraphBody = new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", results).toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The broker check uses the grant the broker uses (token exchange) with no
|
||||
** subject: invalid_request proves the credentials; nothing is ever issued.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void brokerStates() throws Exception
|
||||
{
|
||||
refuse("invalid_request");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.UP);
|
||||
HttpPost request = (HttpPost) FixtureActionUtil.REQUESTS.getLast();
|
||||
String body = EntityUtils.toString(request.getEntity());
|
||||
assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", "client_id=", "client_secret=");
|
||||
assertThat(body).doesNotContain("client_credentials", "subject_token");
|
||||
assertThat(request.getFirstHeader("Authorization")).isNull();
|
||||
assertThat(request.getURI().toString()).isEqualTo(config.getTokenEndpointUrl());
|
||||
|
||||
refuse("invalid_client");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
refuse("invalid_grant");
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
respond(new JSONObject().put("access_token", SECRET));
|
||||
assertThat(run("token_broker").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
|
||||
config.withTokenBrokerClientSecret(null);
|
||||
FixtureActionUtil.REQUESTS.clear();
|
||||
var outcome = run("token_broker");
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("not configured");
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve a token-endpoint refusal with a standard OAuth2 error code.
|
||||
*******************************************************************************/
|
||||
private void refuse(String errorCode)
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(400, new JSONObject().put("error", errorCode).toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both response bodies and thrown errors may echo every configured secret.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
|
||||
void failuresNeverExposeSecrets(String name) throws QException
|
||||
{
|
||||
FixtureActionUtil.groupGraphHandler = request -> FixtureActionUtil.response(401, SECRET);
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(401, SECRET);
|
||||
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
FixtureActionUtil.handler = request ->
|
||||
{
|
||||
throw new IllegalStateException(SECRET);
|
||||
};
|
||||
assertThat(run(name).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired input performs no I/O, including unconfigured and mapper checks.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "api", "api_token", "mapper", "token_broker" })
|
||||
void expiredDeadline(String name) throws QException
|
||||
{
|
||||
assertThat(run(name, Instant.now().minusSeconds(1)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retries and HTTP timeouts use the input budget, never the 30-second default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsRequestsAndRetries() throws QException
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(429, SECRET, FixtureActionUtil.retryAfter(30));
|
||||
assertThat(run("api_token", Instant.now().plusSeconds(2)).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).hasSize(1);
|
||||
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
|
||||
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectionRequestTimeout()).isBetween(1, 2000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unlimited normal client settings cannot override the central deadline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsUnlimitedTimeouts() throws QException
|
||||
{
|
||||
config.withConnectTimeoutMillis(0).withSocketTimeoutMillis(0);
|
||||
run("api_token", Instant.now().plusSeconds(2));
|
||||
var settings = FixtureActionUtil.REQUESTS.getFirst().getConfig();
|
||||
assertThat(settings.getSocketTimeout()).isBetween(1, 2000);
|
||||
assertThat(settings.getConnectTimeout()).isBetween(1, 2000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A saturated throttle refuses a reservation beyond the health deadline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundsThrottle() throws QException
|
||||
{
|
||||
TokenBucketThrottle throttle = new TokenBucketThrottle(1, 1);
|
||||
throttle.acquire(Instant.now().plusSeconds(1));
|
||||
assertThatThrownBy(() -> throttle.acquire(Instant.now().plusMillis(10))).isInstanceOf(QException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central permission does not grant Authentik mutations or arbitrary users.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requiresHealthPermission() throws QException
|
||||
{
|
||||
AuthentikTestSupport.loginWithPermissions(instance, Set.of());
|
||||
assertThat(run("api").getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(FixtureActionUtil.REQUESTS).isEmpty();
|
||||
assertThatThrownBy(() -> AuthentikActionUtil.forBackend(config.getApiBackendName()).withHealthDeadline(Instant.now().plusSeconds(1)))
|
||||
.hasMessageContaining("permission");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the registered production check, with a short normal budget.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(String name) throws QException
|
||||
{
|
||||
return (run(name, Instant.now().plusSeconds(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HealthRunner.sanitize is package-private; assert the raw outcome is clean.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(String name, Instant deadline) throws QException
|
||||
{
|
||||
var definition = HealthChecksMetaData.ofOrWithNew(instance).getChecks().stream()
|
||||
.filter(c -> c.getName().equals("authentik." + name)).findFirst().orElseThrow();
|
||||
var outcome = definition.getCheckInstance().run(new HealthCheckInput().withQInstance(instance).withDeadline(deadline));
|
||||
assertClean(outcome);
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No credential can appear even before core sanitization.
|
||||
*******************************************************************************/
|
||||
private void assertClean(HealthCheckOutcome outcome)
|
||||
{
|
||||
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SECRET, "authentik.example.test", "operator@example.test");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve a JSON response through the existing fixture handler.
|
||||
*******************************************************************************/
|
||||
private void respond(JSONObject body)
|
||||
{
|
||||
FixtureActionUtil.handler = request -> FixtureActionUtil.response(200, body.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve paginated metadata without any network.
|
||||
*******************************************************************************/
|
||||
private void page(JSONObject... records)
|
||||
{
|
||||
respond(new JSONObject(FixtureActionUtil.EMPTY_PAGE).put("results", new JSONArray(List.of(records))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Let version probes use the same configurable fixture handler as other calls.
|
||||
*******************************************************************************/
|
||||
public static class HealthActionUtil extends FixtureActionUtil
|
||||
{
|
||||
/***************************************************************************
|
||||
** Preserve the existing group graph seam and capture version requests.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected QHttpResponse send(HttpRequestBase request)
|
||||
{
|
||||
if(request.getURI().getPath().endsWith("/admin/version/"))
|
||||
{
|
||||
REQUESTS.add(request);
|
||||
return (handler.apply(request));
|
||||
}
|
||||
return (super.send(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user