Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 5cf668f8e3 feat(help): ship the qbit-authentik help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:00 -05:00
12 changed files with 1165 additions and 221 deletions

No files matched your search

+4 -1
View File
@@ -4,7 +4,10 @@ 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, 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.
- 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.
- 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 inline by `AuthentikHelp` as well as registered with core, 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.
+20 -2
View File
@@ -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.6 (released BOM, no core SNAPSHOT). Java 21. |
| QQQ version | QQQ 4.1.0-RC.7 (released BOM, no core SNAPSHOT). 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)`. |
@@ -128,7 +128,7 @@ Access-token metadata is readable with `authentik.audit.read` or `authentik.toke
The **Access tokens** home widget requires `authentik.tokens.manage` and uses the standard TABLE widget. Its links use supported owner or identifier filters; creation time and last use are computed, not API-filterable or sortable. The Revoke Token action accepts 1–`MAX_BULK` selected tokens from either the record or list.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The temporary session-permission intersection is marked `STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7)`; a reflection bridge also attaches `QCredentialScope` when available. It does not add permissions.
Hosts with their own authentication customizer can call `AuthentikTokenScopes.apply(session, claims)` **after** computing permissions. The qbit's two claims mappers already do this. Scope and token-existence results are cached per runtime/identifier for 60 seconds; API/lookup failures deny access. The qbit conservatively narrows session permissions for direct consumers and attaches core's `QCredentialScope` for framework enforcement. It does not add permissions.
The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at INFO and skip delivery when the optional adapter is unavailable; skipped reminders are not marked delivered. With the newer core Alerts SPI and qbit-alerts installed, the reflection adapter sends owner notifications and raises guard incidents (dedupe key `authentik_token_guard_bypass`, mapped to core CRITICAL severity). A host may install a per-runtime `AuthentikTokenAlerts.Sink`. The daily scheduler's system session is trusted; manual runs require `authentik.tokens.manage`.
@@ -136,3 +136,21 @@ The RC.2 classpath has no Alerts producer SPI. Reminders and guard alerts log at
Service-account creation merges `goauthentik.io/user/token-maximum-lifetime` into the new user attributes using `serviceAccountTokenLifetime` (default `days=365`). No lifetime group is required; only the requested, permitted groups are added.
Service-account creation and rotation need `add_token` (verified on Authentik 2026.8.3 by `TokenContractIT`).
## In-app help
The qbit ships `src/main/resources/help/qbit-authentik.yaml` and registers it with
QQQ's help bundle registry (QQQ 4.1.0-RC.7). Registration is metadata-only; core
skips startup synchronization when the host has no `helpContent` table. Inline
app and table help remains available without help storage.
To override wording, register a separate bundle with `layer: HOST`, its own bundle
ID and version, and entries with the same keys and roles. HOST entries take
precedence over QBIT entries; do not edit the dependency's resource. Keep deployment
names, navigation and authentication timing in the host bundle.
`AuthentikHelpBundleTest` enables every supported feature and uses
`HelpBundleAssertions` to require coverage and validate keys, summaries and live
references. Run it with `./mvnw test -Dtest=AuthentikHelpBundleTest`.
The bundle version is the literal project version; update it when releasing.
+2 -2
View File
@@ -239,7 +239,7 @@
<profiles>
<!-- Needs qqq 4.1 (QBit producer API). Until qbit-build-parent imports the -->
<!-- released 4.1 BOM, this profile is active by default and imports -->
<!-- qqq-bom-pom 4.1.0-RC.5 ahead of the parent 4.0.0 BOM, as in -->
<!-- qqq-bom-pom 4.1.0-RC.7 ahead of the parent 4.0.0 BOM, as in -->
<!-- qbit-sql-workbench and qbit-quick-search (ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
@@ -247,7 +247,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
@@ -191,6 +192,8 @@ public class AuthentikQBitProducer implements QBitMetaDataProducer<AuthentikQBit
{
qInstance.addProcess(process);
}
// Registration is metadata-only; core skips startup sync without a helpContent table.
HelpBundles.register(qInstance, "help/qbit-authentik.yaml");
AuthentikHelp.apply(qInstance, qBitConfig);
LOG.info("Authentik QBit produced", logPair("version", VERSION), logPair("appKey", qBitConfig.getAppKey()),
@@ -17,17 +17,16 @@
package com.kingsrook.qbits.authentik.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -40,8 +39,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
**
** The bundle's keys use the default table prefix; a host's own tableNamePrefix
** is applied to them, so renamed tables get the same text.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.7): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
** Inline fallback for hosts without help storage; core owns bundle validation
** and the producer also registers the bundle for startup synchronization.
*******************************************************************************/
public final class AuthentikHelp
{
@@ -66,33 +65,14 @@ public final class AuthentikHelp
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AuthentikHelp.class.getResourceAsStream(RESOURCE))
Map<String, JsonNode> entries = new LinkedHashMap<>();
var bundle = HelpBundles.load(RESOURCE.substring(1), AuthentikHelp.class.getClassLoader());
ObjectMapper mapper = new ObjectMapper();
for(var entry : bundle.getEntries())
{
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<String, JsonNode> 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));
entries.put(entry.getKey(), mapper.valueToTree(entry));
}
return (entries);
}
@@ -103,25 +83,37 @@ public final class AuthentikHelp
*******************************************************************************/
public static void apply(QInstance qInstance, AuthentikQBitConfig config) throws QException
{
for(var bundle : HelpBundles.of(qInstance).getBundles())
{
if("qbit-authentik".equals(bundle.getId()))
{
for(var entry : bundle.getEntries())
{
entry.setKey(configuredNames(entry.getKey(), config));
entry.setSummary(configuredNames(entry.getSummary(), config));
entry.setContent(configuredNames(entry.getContent(), config));
}
}
}
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
{
String key = entry.getKey();
if(key.startsWith("table:"))
if(key.startsWith("table:") && key.endsWith(";slot:help"))
{
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()));
table.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
else if(key.startsWith("app:"))
else if(key.startsWith("app:") && key.endsWith(";slot:help"))
{
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()));
app.withHelpContent(SLOT, content(entry.getValue(), config));
}
}
}
@@ -133,9 +125,35 @@ public final class AuthentikHelp
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
static QHelpContent content(JsonNode entry, AuthentikQBitConfig config)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
return (new QHelpContent().withContentAsMarkdown(configuredNames(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip(), config)));
}
/*******************************************************************************
** Follow the host prefix in keys and live references; status enums are fixed.
*******************************************************************************/
private static String configuredNames(String text, AuthentikQBitConfig config)
{
if(text == null)
{
return (null);
}
return (Pattern.compile("authentik_[a-zA-Z0-9_]+|authentik[A-Z][a-zA-Z0-9]*").matcher(text).replaceAll(match ->
{
String name = match.group();
if(name.equals("authentik_action_status") || name.equals("authentik_approval_status"))
{
return (name);
}
if(name.startsWith(DEFAULT_TABLE_PREFIX))
{
return (config.applyTablePrefix(name.substring(DEFAULT_TABLE_PREFIX.length())));
}
return (config.applyPrefix(name.substring("authentik".length())));
}));
}
}
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.authentik.processes;
import com.kingsrook.qbits.authentik.admin.OneTimeKey;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret;
/*******************************************************************************
@@ -43,11 +44,11 @@ public interface TokenKeyDelivery
/*******************************************************************************
** Current adapter; null restores the safe reflection adapter.
** Current adapter; null restores the core response-only adapter.
*******************************************************************************/
static void set(TokenKeyDelivery delivery)
{
Holder.delivery = delivery == null ? new ReflectiveDelivery() : delivery;
Holder.delivery = delivery == null ? new CoreDelivery() : delivery;
}
@@ -90,7 +91,7 @@ public interface TokenKeyDelivery
*******************************************************************************/
final class Holder
{
private static volatile TokenKeyDelivery delivery = new ReflectiveDelivery();
private static volatile TokenKeyDelivery delivery = new CoreDelivery();
/*******************************************************************************
** Utility holder.
@@ -103,56 +104,18 @@ public interface TokenKeyDelivery
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.7)
** Reflection keeps RC.2 binary-compatible and never falls back to values.
** Core response-only delivery; credentials never enter process values.
*******************************************************************************/
final class ReflectiveDelivery implements TokenKeyDelivery
final class CoreDelivery implements TokenKeyDelivery
{
/*******************************************************************************
** Check the complete core contract before any irreversible key mutation.
*******************************************************************************/
@Override
public boolean available()
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
type.getConstructor();
for(String field : new String[] { "Label", "Value", "Masked", "UsageText" })
{
type.getMethod("with" + field, String.class);
}
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type);
return (true);
}
catch(ReflectiveOperationException e)
{
return (false);
}
}
/*******************************************************************************
** Add to core's response-only channel; never include failures' contents.
** Add to core's response-only channel.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.secrets.OneTimeSecret");
Object secret = type.getConstructor().newInstance();
type.getMethod("withLabel", String.class).invoke(secret, key.label());
type.getMethod("withValue", String.class).invoke(secret, key.value());
type.getMethod("withMasked", String.class).invoke(secret, key.masked());
type.getMethod("withUsageText", String.class).invoke(secret, key.usageText());
RunBackendStepOutput.class.getMethod("addOneTimeSecret", type).invoke(output, secret);
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("One-time output unavailable"));
}
output.addOneTimeSecret(new OneTimeSecret().withLabel(key.label()).withValue(key.value())
.withMasked(key.masked()).withUsageText(key.usageText()));
}
}
}
@@ -17,11 +17,9 @@
package com.kingsrook.qbits.authentik.security;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -39,6 +37,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
@@ -262,7 +261,7 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1153, removeBy=4.1.0-RC.7): intersect, never grant.
** Intersect permissions for direct session consumers and attach core scope.
*******************************************************************************/
public static void narrow(QSession session, TokenScope scope)
{
@@ -272,8 +271,8 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Keep only permitted bases and read operations, then attach core's scope
** when available. Management hasAccess permissions are not read operations.
** Keep conservative permission narrowing for direct session consumers, then
** attach core scope for framework enforcement. Management is not read-only.
*******************************************************************************/
private static void narrow(QInstance instance, QSession session, TokenScope scope)
{
@@ -410,10 +409,6 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** Optional core bridge. An incompatible installed contract fails closed;
** an absent class uses the permission intersection above.
*******************************************************************************/
/*******************************************************************************
** An empty scope dimension means "not restricted" (null for core).
*******************************************************************************/
@@ -425,51 +420,18 @@ public final class AuthentikTokenScopes
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1168, removeBy=4.1.0-RC.7): until the qbit's QQQ
** version has QCredentialScope (qqq#1153), narrow() above enforces the scope
** by intersecting the session's permissions; this attaches the core scope by
** reflection when it exists. Replace both with a direct withCredentialScope.
** Attach the released core scope contract; a revoked token allows nothing.
*******************************************************************************/
private static void attachCoreScope(QSession session, TokenScope scope)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.model.session.QCredentialScope");
Object value = type.getConstructor().newInstance();
///////////////////////////////////////////////////////////////////////
// core treats a null set as unrestricted and an empty set as //
// "nothing": an unlisted dimension must therefore be passed as null //
///////////////////////////////////////////////////////////////////////
Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("CredentialId", scope.identifier());
fields.put("ReadOnly", scope.readOnly());
fields.put("Apps", nullIfEmpty(scope.apps()));
fields.put("Tables", nullIfEmpty(scope.tables()));
fields.put("Processes", nullIfEmpty(scope.processes()));
fields.put("Reports", nullIfEmpty(scope.reports()));
fields.put("Widgets", nullIfEmpty(scope.widgets()));
fields.put("OperationIds", nullIfEmpty(scope.operationIds()));
for(var field : fields.entrySet())
{
Method setter = Arrays.stream(type.getMethods()).filter(m -> m.getName().equals("with" + field.getKey()) && m.getParameterCount() == 1)
.findFirst().orElseThrow(NoSuchMethodException::new);
Object arg = field.getValue();
if(arg instanceof Set<?> set && List.class.isAssignableFrom(setter.getParameterTypes()[0]))
{
arg = List.copyOf(set);
}
setter.invoke(value, arg);
}
session.getClass().getMethod("withCredentialScope", type).invoke(session, value);
}
catch(ClassNotFoundException ignored)
{
// RC.2 has no credential scope; the stand-in above is active.
}
catch(ReflectiveOperationException | IllegalArgumentException e)
{
session.setPermissions(new TreeSet<>());
LOG.warn("The installed credential-scope contract is incompatible; denying credential permissions.");
}
boolean denied = scope.mode() == Mode.DENY_ALL;
session.withCredentialScope(new QCredentialScope().withCredentialId(scope.identifier())
.withReadOnly(denied || scope.readOnly())
.withApps(denied ? Set.of() : nullIfEmpty(scope.apps()))
.withTables(denied ? Set.of() : nullIfEmpty(scope.tables()))
.withProcesses(denied ? Set.of() : nullIfEmpty(scope.processes()))
.withReports(denied ? Set.of() : nullIfEmpty(scope.reports()))
.withWidgets(denied ? Set.of() : nullIfEmpty(scope.widgets()))
.withOperationIds(denied ? Set.of() : nullIfEmpty(scope.operationIds())));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.authentik;
import java.util.EnumSet;
import com.kingsrook.qbits.authentik.admin.AdminTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of the shipped help with all supported optional metadata present.
*******************************************************************************/
class AuthentikHelpBundleTest
{
/*******************************************************************************
** New objects and non-trivial fields must ship with valid help.
*******************************************************************************/
@Test
void bundleCoversOwnedMetadata() throws Exception
{
var support = AdminTestSupport.create(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.TOKENS,
AuthentikFeature.POLICIES, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.DELETE,
AuthentikFeature.MEMBERSHIP, AuthentikFeature.CREDENTIALS, AuthentikFeature.SESSIONS));
HelpBundleAssertions.forInstance(support.qInstance)
.bundles("help/qbit-authentik.yaml")
.owning(target -> target.name().startsWith("authentik"))
.assertComplete();
var bundle = HelpBundles.of(support.qInstance).getBundles().get(0);
assertThat(bundle.getVersion()).isEqualTo(AuthentikQBitProducer.getVersion());
var keys = bundle.getEntries().stream().map(entry -> entry.getKey()).toList();
assertThat(keys).doesNotHaveDuplicates();
// Core's gate currently requires apps, tables, actions and table fields.
// Also pin coverage for this qbit's widgets and possible-value sources.
support.qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
support.qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("authentik"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
}
/*******************************************************************************
** Metadata-only registration is safe without help storage and follows prefixes.
*******************************************************************************/
@Test
void registersWithoutHelpStorage() throws Exception
{
var instance = AuthentikTestSupport.produce(AuthentikTestSupport.unitConfig().withTableNamePrefix("idp"));
assertThat(instance.getTable("helpContent")).isNull();
HelpBundles.syncOnStartup(instance);
var bundles = HelpBundles.of(instance).getBundles();
assertThat(bundles).hasSize(1);
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:idp_user;slot:help");
assertThat(entry.getContent()).contains("{{label:idp_user}}").doesNotContain("{{label:authentik");
});
}
}
@@ -24,7 +24,6 @@ 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;
@@ -160,11 +159,7 @@ class AuthentikTablesGuardrailTest
}
}
assertThat(instance.getApp("authentikAdmin").getHelpContent()).containsKey(AuthentikHelp.SLOT);
for(Map.Entry<String, JsonNode> 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();
}
}
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -299,7 +300,26 @@ class TokenManagementTest
t.config.withTokenBrokerClientSecret("test-secret").withSubjectTokenSource(session -> null);
refused(create(), "signed-in Authentik session");
t.config.withSubjectTokenSource(session -> "subject:3");
TokenKeyDelivery.set(null);
TokenKeyDelivery.set(new TokenKeyDelivery()
{
/*******************************************************************************
** Simulate a host without a usable response channel.
*******************************************************************************/
@Override
public boolean available()
{
return (false);
}
/*******************************************************************************
** Delivery must never be attempted while unavailable.
*******************************************************************************/
@Override
public void deliver(RunBackendStepOutput output, OneTimeKey key)
{
throw (new AssertionError("Unavailable delivery was called"));
}
});
refused(create(), "upgrade QQQ");
seed("own", 3);
refused(AdminRequest.of(AdminAction.ROTATE_TOKEN, "own", "replace"), "upgrade QQQ");
@@ -530,4 +550,21 @@ class TokenManagementTest
assertThat(t.qInstance.getTable("authentik_token_scope").getFields()).doesNotContainKey("key");
assertThat(t.config.toString()).doesNotContain("test-broker-secret");
}
/*******************************************************************************
** RC.7 delivers credentials only through the response-only secret channel.
*******************************************************************************/
@Test
void coreOneTimeDelivery()
{
TokenKeyDelivery.set(null);
RunBackendStepOutput output = new RunBackendStepOutput();
OneTimeKey key = new OneTimeKey("Token", "test-key-not-a-credential", "Copy once");
assertThat(TokenKeyDelivery.get().available()).isTrue();
assertThat(TokenKeyDelivery.tryDeliver(output, key)).isTrue();
assertThat(output.getOneTimeSecrets()).hasSize(1);
assertThat(output.getOneTimeSecrets().get(0).getValue()).isEqualTo(key.value());
assertThat(output.getValues()).doesNotContainValue(key.value());
}
}
@@ -121,11 +121,15 @@ class TokenScopesTest
JSONObject claims = new JSONObject().put("qqq_token", "limited");
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).containsExactlyInAnyOrder("cms.publish.hasAccess", "post.read");
assertThat(session.getCredentialScope().getReadOnly()).isTrue();
assertThat(session.getCredentialScope().getTables()).isNull();
t.authentik.tokens.get("sam").clear();
AuthentikRuntime.forBackend(t.config.getApiBackendName()).invalidateCache();
AuthentikTokenScopes.apply(session, claims);
assertThat(session.getPermissions()).isEmpty();
assertThat(AuthentikTokenScopes.forClaims(t.qInstance, claims).orElseThrow().mode()).isEqualTo(Mode.DENY_ALL);
assertThat(session.getCredentialScope().getTables()).isEmpty();
assertThat(session.getCredentialScope().getOperationIds()).isEmpty();
assertThat(new TokenScopeStore(t.config).find("limited")).isNotNull();
}