From f0648d1ef2349981ce702a3eab4d65e657dbb878 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:44:21 -0500 Subject: [PATCH 1/2] feat(help): guided empty states for every authentik table Add help/qbit-authentik.yaml (QQQ help-bundle format) for the Identity app and all 15 tables, applied by AuthentikHelp (STAND-IN for core #1198). API-backed tables say rows are read live and what an empty list means. Add AuthentikTablesGuardrailTest (used at runtime, explained, documented). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 9 + UPGRADE.md | 4 + .../authentik/AuthentikQBitProducer.java | 2 + .../authentik/metadata/AuthentikHelp.java | 141 +++++++++++ src/main/resources/help/qbit-authentik.yaml | 93 +++++++ .../AuthentikTablesGuardrailTest.java | 237 ++++++++++++++++++ 7 files changed, 487 insertions(+) create mode 100644 src/main/java/com/kingsrook/qbits/authentik/metadata/AuthentikHelp.java create mode 100644 src/main/resources/help/qbit-authentik.yaml create mode 100644 src/test/java/com/kingsrook/qbits/authentik/AuthentikTablesGuardrailTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ba30d..afa7b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198)`), which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes. - `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only. - `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered. diff --git a/README.md b/README.md index fb39368..d7ec2c5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,15 @@ Read-only is the default (`features` = `READ`). `readOnly=true` is a hard kill s | App and home | `authentikAdmin` (label "Identity", hosts may nest it) with the `authentikHome` widget and the drift report | | Actions | Create User, Invite User, Edit User, Deactivate User, Reactivate User, Add To Group, Remove From Group, Send Password Reset Email, Reset MFA, Revoke App Passwords, Revoke Sessions, Create Group, Edit Group, Edit Permissions And App Access, Add Members, Remove Members, Cancel Invitation, Approve And Apply, Reject; with `POLICIES`: Edit Policy (on groups and policies); with `DELETE`: Delete User, Delete Group | +### Tables, starter data and empty screens + +Nothing is seeded: there are no starter defaults to include in a changelog. Setup comes from `AuthentikQBitConfig` (URL, API token, features, read-only mode), and the Identity home's "Needs attention" card lists what is left. The tables are of two kinds: + +- **API-backed (live):** `authentik_user`, `_group`, `_group_member` (virtual), `_session`, `_event`, `_application`, `_policy_binding`, `_blueprint`, `_role`, `_token`, `_invitation`. Every row is read from Authentik on each view and nothing is stored locally. An empty list means Authentik returned nothing to you: your role lacks the permission, the qbit's feature or invitation flow is not enabled, or Authentik has no such records. Read-only mode only blocks changes. +- **Local (the qbit's own records):** `authentik_admin_action` (audit trail), `authentik_approval_request`, `authentik_policy` and `authentik_token_scope`. They fill as people run actions, and are written only by the qbit. + +Every table, and the app, ships help (`help/qbit-authentik.yaml`, the QQQ help-bundle format) that says what the table is for, how its rows get created and, for the live tables, what an empty list means. The Next UI shows it as the empty state. A host's own help on a table is never replaced, and a host's `tableNamePrefix` is applied to the bundle's table names. `AuthentikTablesGuardrailTest` fails when a table is added without runtime use, a documented reason it starts empty, and this help. + ### Permissions QQQ permissions, held as `.hasAccess`, granted through the group attribute `qqq.io/permissions` and read by `AuthentikGroupPermissionMapper`: diff --git a/UPGRADE.md b/UPGRADE.md index 5e8934c..08ac884 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,9 @@ # Upgrading qbit-authentik +## Table help bundle (unreleased) + +Nothing to do. The qbit now applies `help/qbit-authentik.yaml` to the Identity app and its tables at the end of `produce()`, filling only help slots the host has not set. There is no schema change and nothing to seed or include in a Liquibase changelog: the API-backed tables are live Authentik data, and the four local tables fill as people use the actions. + ## QQQ 4.1.0-RC.6 baseline (#20 / F19) Hosts should align their QQQ dependencies with the released `qqq-bom-pom:4.1.0-RC.6` diff --git a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java index 661f7cf..70c4453 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitProducer.java @@ -25,6 +25,7 @@ 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.metadata.AuthentikAppMetaDataProducer; +import com.kingsrook.qbits.authentik.metadata.AuthentikHelp; import com.kingsrook.qbits.authentik.processes.AuthentikProcesses; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -190,6 +191,7 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer entries() throws QException + { + try(InputStream stream = AuthentikHelp.class.getResourceAsStream(RESOURCE)) + { + if(stream == null) + { + throw (new IOException("missing resource")); + } + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-authentik".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("invalid bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + String summary = entry.path("summary").asText(); + if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("invalid or duplicate entry: " + key)); + } + } + return (entries); + } + catch(IOException | RuntimeException e) + { + throw (new QException("Could not load " + RESOURCE, e)); + } + } + + + + /******************************************************************************* + ** Apply the help to the app and every table the qbit produced. A host's own + ** help on a table or the app is kept (the bundle only fills the slot). + *******************************************************************************/ + public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException + { + for(Map.Entry entry : entries().entrySet()) + { + String key = entry.getKey(); + if(key.startsWith("table:")) + { + String tableKey = key.substring("table:".length(), key.indexOf(';')); + String unprefixed = tableKey.substring(DEFAULT_TABLE_PREFIX.length()); + QTableMetaData table = qInstance.getTable(config.applyTablePrefix(unprefixed)); + if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT))) + { + table.withHelpContent(SLOT, content(entry.getValue())); + } + } + else if(key.startsWith("app:")) + { + QAppMetaData app = qInstance.getApp(config.applyPrefix(AuthentikAppMetaDataProducer.APP_NAME)); + if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT))) + { + app.withHelpContent(SLOT, content(entry.getValue())); + } + } + } + } + + + + + /******************************************************************************* + ** Summary as the first paragraph, then the content, as markdown. + *******************************************************************************/ + static QHelpContent content(JsonNode entry) + { + return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip())); + } + +} diff --git a/src/main/resources/help/qbit-authentik.yaml b/src/main/resources/help/qbit-authentik.yaml new file mode 100644 index 0000000..5581bc6 --- /dev/null +++ b/src/main/resources/help/qbit-authentik.yaml @@ -0,0 +1,93 @@ +bundle: qbit-authentik +version: 1 +layer: QBIT +# Help for the Identity app and every authentik_* table, in the QQQ help-bundle +# format (qqq#1198). Table keys use the default table prefix (authentik_); a host +# with a different tableNamePrefix gets the same text on its renamed tables. +# AuthentikHelp applies it until the core HelpBundles registry ships in a QQQ +# release (STAND-IN(core=QRun-IO/qqq#1198)). Summaries stay under 120 characters. +# +# Two kinds of table live here. The API-backed tables hold no rows of their own: +# every list is read live from Authentik, so an empty list means "Authentik returned +# nothing to you", never "set this up". The local tables are the qbit's own audit and +# bookkeeping records, written only by the qbit's actions. +entries: + - key: app:authentikAdmin;slot:help + summary: "Manage Authentik users, groups, access and tokens from this app; Authentik stays the source of truth." + content: | + Nothing here needs seeding. Setup comes from the qbit's configuration (Authentik URL, API token, features, read-only mode), and the home's "Needs attention" card lists what is left to do. The user, group, session, event, application, binding, blueprint, role, token and invitation tables are read live from Authentik and have no rows in your database. Four small tables are the qbit's own records: admin actions (the audit trail), approval requests, saved policies and token scopes. They fill as people use the actions. + + - key: table:authentik_user;slot:help + summary: "Authentik users, read live from Authentik: people, service accounts and external users." + content: | + **How rows get created:** you do not add rows to this table directly. The list is read live from Authentik on every view, and users are created with the Create User, Create Service Account and Invite User actions (when those features are on), which call the Authentik API and write an audit row. An empty list means Authentik returned no users to you: your role lacks the Identity read permission, the qbit is not enabled or its API token cannot read users, or Authentik has none. Read-only mode only blocks changes, not this list. + + - key: table:authentik_group;slot:help + summary: "Authentik groups, read live, with their parents, members and the QQQ permissions they grant." + content: | + **How rows get created:** read live from Authentik on every view; create a group with the Create Group action (when the groups feature is on) or in Authentik itself. A group's permissions are the QQQ permissions its attributes grant, edited with Edit Policy or Edit Permissions And App Access. An empty list means Authentik returned no groups to you: no permission, the qbit not enabled, or the API token cannot read groups. + + - key: table:authentik_group_member;slot:help + summary: "Who belongs to which group, assembled live from Authentik; not a stored table." + content: | + **How rows get created:** this is a virtual table. Every row is computed live from the groups' member lists, and there is nothing to seed. Add or remove a member with Add To Group, Remove From Group, Add Members or Remove Members (when the membership feature is on). An empty list means the group or user you are looking at has no members, or you lack the read permission, or the qbit is not enabled. + + - key: table:authentik_session;slot:help + summary: "Signed-in sessions, read live from Authentik. Needs the audit read permission." + content: | + **How rows get created:** Authentik creates a session when someone signs in; the qbit only shows them. End a user's sessions with the Revoke Sessions action on the user (when the sessions feature is on). An empty list means nobody is signed in, or you lack the audit read permission, or the qbit is not enabled. + + - key: table:authentik_event;slot:help + summary: "Authentik's own event log, read live. Needs the audit read permission." + content: | + **How rows get created:** Authentik writes an event for every sign-in, change and failure; the qbit writes only one `custom_qqq_admin_action` event after each admin action, whose id is kept on the matching Admin Actions row. An empty list means no events match, or you lack the audit read permission, or the qbit is not enabled. + + - key: table:authentik_application;slot:help + summary: "Authentik applications (what people sign in to), read live and read-only." + content: | + **How rows get created:** applications are defined in Authentik, usually by a blueprint; the qbit shows them and which groups may open them. An empty list means Authentik has no applications you can see, or you lack the read permission, or the qbit is not enabled. + + - key: table:authentik_policy_binding;slot:help + summary: "Which groups, users or policies are bound to an application, read live from Authentik." + content: | + **How rows get created:** bindings are created in Authentik, on an application. The qbit shows them as a child list on the application and on the group. An empty list means that application has no binding, or you lack the read permission, or the qbit is not enabled. + + - key: table:authentik_blueprint;slot:help + summary: "Authentik blueprints (configuration as code) and whether each last applied cleanly, read live." + content: | + **How rows get created:** blueprints are files or OCI sources that Authentik discovers; the qbit only shows their status. An empty list means Authentik has no blueprint instance, or you lack the read permission, or the qbit is not enabled. + + - key: table:authentik_role;slot:help + summary: "Authentik RBAC roles, read live. Roles are different from groups: they carry Authentik permissions." + content: | + **How rows get created:** roles are created in Authentik; the qbit shows the custom ones (managed system roles are hidden). An empty list means Authentik has no custom roles, or you lack the read permission, or the qbit is not enabled. + + - key: table:authentik_token;slot:help + summary: "Authentik API tokens, read live. Metadata only: the key itself is never shown or stored." + content: | + **How rows get created:** create a token with the Create Token action (when the tokens feature is on); the key is delivered once and never stored. What each token may use is kept in Token Scopes. An empty list means no tokens exist, or you lack the audit read or token manage permission, or the qbit is not enabled. + + - key: table:authentik_invitation;slot:help + summary: "Pending enrolment invitations, read live. Only present when an invitation flow is configured." + content: | + **How rows get created:** the Invite User action creates one through Authentik's invitation stage; the table exists only when the host sets `invitationFlow`. Cancel Invitation withdraws one. An empty list means no invitation is pending, or you lack the read permission. + + - key: table:authentik_admin_action;slot:help + summary: "Audit trail of every change the qbit attempts in Authentik, written before the call is made." + content: | + **How rows get created:** every user, group, membership, credential, session, token and policy action writes one row first (status Running), then moves it to Succeeded, Failed or Refused; a change that needs a second person shows Waiting for approval. Rows are never edited by hand or deleted, so a mistake can be traced. The Authentik Event Pk column is the id of the matching `custom_qqq_admin_action` event in Authentik's Events, for cross-reference. An empty table means no action has been attempted yet. + + - key: table:authentik_approval_request;slot:help + summary: "Changes waiting for a second person: granting authentik.* permissions needs two people." + content: | + **How rows get created:** when an operator grants authentik.* permissions, the qbit stores the change here instead of applying it. A different person with the Approve permission uses Approve And Apply or Reject; requests nobody reviews expire. An empty table means nothing is waiting. + + - key: table:authentik_policy;slot:help + summary: "The last saved policy document for each group, with who saved it and when." + content: | + **How rows get created:** the Edit Policy action writes one row per group when a policy is saved (the live document stays in Authentik). Field-level history of changes is kept. Only present when the policies feature is on. An empty table means no policy has been saved yet; open a group and choose Edit Policy. + + - key: table:authentik_token_scope;slot:help + summary: "What each token may use: apps, tables, processes, reports, widgets and operations. No key material." + content: | + **How rows get created:** Create Token writes a scope row when you give the token a scope (token scopes enabled), and the expiry reminder job stamps when it last reminded. Only present when the tokens feature is on. An empty table means no scoped token has been created yet. diff --git a/src/test/java/com/kingsrook/qbits/authentik/AuthentikTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/authentik/AuthentikTablesGuardrailTest.java new file mode 100644 index 0000000..dccc591 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/authentik/AuthentikTablesGuardrailTest.java @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.authentik; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.authentik.admin.AdminTestSupport; +import com.kingsrook.qbits.authentik.client.AuthentikTables; +import com.kingsrook.qbits.authentik.metadata.AuthentikHelp; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Guardrail (owner requirement 2026-10-14: "all tables should be populated and + ** the qbit should be fully using them"). For every authentik_* table: + ** - it is used at runtime: an API-backed table through its backend (every row + ** is read live from Authentik by AuthentikActionUtil, so it has no rows of + ** its own); a local table by runtime code that reads or writes it, not only + ** its metadata or the home screen; + ** - it has nothing to seed (setup comes from config; every table is live data + ** or a log), and says why in STARTS_EMPTY; + ** - it has bundle help that says what it is for and how its rows get created + ** (shown on the table screen and when it is empty). + ** A new table fails this test until it is wired, explained and documented. + *******************************************************************************/ +class AuthentikTablesGuardrailTest +{ + private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "authentik"); + + /******************************************************************************* + ** The API-backed tables: rows are read live from Authentik, never stored. + *******************************************************************************/ + static final List API_TABLES = List.of("authentik_user", "authentik_group", "authentik_group_member", "authentik_session", "authentik_event", + "authentik_application", "authentik_policy_binding", "authentik_blueprint", "authentik_role", "authentik_token", "authentik_invitation"); + + /******************************************************************************* + ** The qbit's own tables, with the constant runtime code uses to reach them. + *******************************************************************************/ + static final Map LOCAL_TABLES = Map.of( + "authentik_admin_action", "AuthentikLocalTables.ADMIN_ACTION", + "authentik_approval_request", "AuthentikLocalTables.APPROVAL_REQUEST", + "authentik_policy", "AuthentikLocalTables.POLICY", + "authentik_token_scope", "AuthentikLocalTables.TOKEN_SCOPE"); + + /******************************************************************************* + ** Why no table is seeded. + *******************************************************************************/ + static final Map STARTS_EMPTY = Map.ofEntries( + Map.entry("authentik_user", "live Authentik data, no rows of its own; setup is the qbit config"), + Map.entry("authentik_group", "live Authentik data, no rows of its own"), + Map.entry("authentik_group_member", "virtual: computed live from group member lists"), + Map.entry("authentik_session", "live Authentik data: signed-in sessions"), + Map.entry("authentik_event", "live Authentik data: its event log"), + Map.entry("authentik_application", "live Authentik data, defined by blueprints in Authentik"), + Map.entry("authentik_policy_binding", "live Authentik data, defined on applications in Authentik"), + Map.entry("authentik_blueprint", "live Authentik data: blueprint status"), + Map.entry("authentik_role", "live Authentik data: RBAC roles"), + Map.entry("authentik_token", "live Authentik data: token metadata, never the key"), + Map.entry("authentik_invitation", "live Authentik data: pending invitations"), + Map.entry("authentik_admin_action", "log: one row per action a person runs"), + Map.entry("authentik_approval_request", "log: a second-person approval is requested"), + Map.entry("authentik_policy", "history: written when a person saves a group policy"), + Map.entry("authentik_token_scope", "history: written when a scoped token is created")); + + + + /******************************************************************************* + ** Every produced table is accounted for here, and the other way round. + *******************************************************************************/ + @Test + void everyTableIsKnown() throws Exception + { + QInstance instance = support().qInstance; + List produced = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("authentik_")).sorted().toList(); + assertThat(produced).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet()); + assertThat(STARTS_EMPTY.keySet()).containsAll(API_TABLES).containsAll(LOCAL_TABLES.keySet()); + assertThat(STARTS_EMPTY).hasSize(API_TABLES.size() + LOCAL_TABLES.size()); + } + + + + /******************************************************************************* + ** Every table is used at runtime: an API table through its backend, a local + ** table by non-metadata code. + *******************************************************************************/ + @Test + void everyTableIsUsedAtRuntime() throws Exception + { + AdminTestSupport support = support(); + for(String table : API_TABLES) + { + QTableMetaData metaData = support.qInstance.getTable(table); + assertThat(metaData).as(table).isNotNull(); + assertThat(metaData.getBackendName()).as(table + " is served by the Authentik API backend").isEqualTo(support.config.getApiBackendName()); + assertThat(AuthentikTables.get(specName(table))).as(table + " has an API spec").isNotNull(); + } + + List runtimeSource; + try(Stream files = Files.walk(MAIN)) + { + runtimeSource = files.filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))) + .filter(p -> !p.endsWith("AuthentikLocalTables.java")) + .map(AuthentikTablesGuardrailTest::read).toList(); + } + for(Map.Entry local : LOCAL_TABLES.entrySet()) + { + assertThat(runtimeSource.stream().anyMatch(source -> source.contains(local.getValue()))) + .as(local.getKey() + " (" + local.getValue() + ") must be read or written by runtime code, not only declared").isTrue(); + assertThat(support.qInstance.getTable(local.getKey()).getBackendName()).isEqualTo(AdminTestSupport.LOCAL); + } + } + + + + /******************************************************************************* + ** Every table and the app have bundle help that says how rows get created, + ** and what an empty list means; the API-backed ones say rows are live. + *******************************************************************************/ + @Test + void everyTableHasEmptyStateHelp() throws Exception + { + QInstance instance = support().qInstance; + assertThat(AuthentikHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120)); + for(String table : STARTS_EMPTY.keySet()) + { + List help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(AuthentikHelp.SLOT); + assertThat(help).as(table + " table help").isNotEmpty(); + String text = help.get(0).getContent(); + assertThat(text).as(table + " help says how rows get created").contains("How rows get created"); + assertThat(text).as(table + " help says what empty means").containsAnyOf("An empty list means", "An empty table means"); + if(API_TABLES.contains(table)) + { + assertThat(text).as(table + " help says rows are live").containsAnyOf("live", "Live"); + } + } + assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT); + for(Map.Entry entry : AuthentikHelp.entries().entrySet()) + { + String key = entry.getKey(); + assertThat(key.startsWith("app:") || STARTS_EMPTY.containsKey(key.substring(6, key.indexOf(';')))).as("help names only real tables: " + key).isTrue(); + } + } + + + + /******************************************************************************* + ** A host with its own table prefix gets the same help on the renamed tables, + ** and a host's own help is never replaced. + *******************************************************************************/ + @Test + void helpFollowsTheHostPrefixAndKeepsHostHelp() throws Exception + { + AuthentikQBitConfig config = AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"); + QInstance idp = AuthentikTestSupport.produce(config); + assertThat(idp.getTable("idp_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).contains("How rows get created"); + assertThat(idp.getApp("idpAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT); + + QInstance hosted = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig()); + hosted.getTable("authentik_user").getHelpContent().put(AuthentikHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text"))); + AuthentikHelp.apply(hosted, AuthentikTestSupport.unitConfig()); + assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT)).hasSize(1); + assertThat(hosted.getTable("authentik_user").getHelpContent().get(AuthentikHelp.SLOT).get(0).getContent()).isEqualTo("host text"); + } + + + + /******************************************************************************* + ** Every feature on, local tables on a memory backend, an invitation flow set. + *******************************************************************************/ + private static AdminTestSupport support() throws Exception + { + return (AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS, AuthentikFeature.POLICIES, AuthentikFeature.USERS, + AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS))); + } + + + + /******************************************************************************* + ** "authentik_group_member" -> "groupMember" (the spec's own name). + *******************************************************************************/ + private static String specName(String table) + { + String name = table.substring("authentik_".length()); + for(String spec : AuthentikTables.all().keySet()) + { + if(AuthentikQBitConfig.toSnakeCase(spec).equals(name)) + { + return (spec); + } + } + return (name); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String read(Path path) + { + try + { + return (Files.readString(path, StandardCharsets.UTF_8)); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + +} -- 2.54.0 From a6daba12dfe851d72726a6a0dedbe9a23dc67b79 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:10:26 -0500 Subject: [PATCH 2/2] docs: add removeBy to the STAND-IN markers in the help bundle and changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/main/resources/help/qbit-authentik.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa7b80..a766f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] -- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198)`), which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes. +- Guided empty states: `help/qbit-authentik.yaml` (QQQ help-bundle format) gives the Identity app and all 15 tables a summary and a "How rows get created" section; the 11 API-backed tables say they are read live from Authentik and what an empty list means (no permission, feature or invitation flow not enabled, or Authentik has none). Applied by the new `AuthentikHelp` stand-in (`STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)`), which fills only empty slots and follows the host's `tableNamePrefix`. `AuthentikTablesGuardrailTest` pins that every table is used at runtime, explained and documented. No schema, config or seed changes. - `AuthentikQBitConfig.fullAccessHostPermissions` lets a host declare permission names core cannot enumerate (custom checkers, named permissions); full access expands to them at use time, for the instance holding that runtime's backend only. - `AuthentikMaintenance.applyPolicy` lets reviewed host run-once jobs apply a policy to an exact group name through the existing audited Save Policy path. Dedicated maintenance identities, full-access expansion, canonical no-ops, context restoration, stale checks and optional two-person approval are covered; system-session writes remain forbidden. No HTTP route or process is registered. diff --git a/src/main/resources/help/qbit-authentik.yaml b/src/main/resources/help/qbit-authentik.yaml index 5581bc6..fe254c7 100644 --- a/src/main/resources/help/qbit-authentik.yaml +++ b/src/main/resources/help/qbit-authentik.yaml @@ -5,7 +5,7 @@ layer: QBIT # format (qqq#1198). Table keys use the default table prefix (authentik_); a host # with a different tableNamePrefix gets the same text on its renamed tables. # AuthentikHelp applies it until the core HelpBundles registry ships in a QQQ -# release (STAND-IN(core=QRun-IO/qqq#1198)). Summaries stay under 120 characters. +# release (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7)). Summaries stay under 120 characters. # # Two kinds of table live here. The API-backed tables hold no rows of their own: # every list is read live from Authentik, so an empty list means "Authentik returned -- 2.54.0