Files
qbit-authentik/src/main/java/com/kingsrook/qbits/authentik/AuthentikQBitConfig.java
T
james.maesandClaude Opus 5.5 d56b832197 docs(readme): add Adopting in another app; neutral examples (#3)
Adds the adoption table proposed in #3, verified against the config and
metadata producers, linking the QQQ adoption checklist. README and Javadoc
examples use neutral names (my-app-admin, my-app-prod); the kof22 test
blueprints are described as one host's example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:08:36 -05:00

968 lines
33 KiB
Java

/*
* 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.net.URI;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Host-facing configuration of the Authentik QBit (DESIGN.md section 3.2).
**
** Secrets (apiToken, webhookSecret) may be given as ${env.NAME} references;
** the qbit resolves them itself (APIBackendMetaData.enrich() is disabled in
** QQQ), never serializes them, and redacts them in toString().
**
** validate() is list-based and never throws; the producer fails production
** with all collected errors.
*******************************************************************************/
public class AuthentikQBitConfig implements QBitConfig
{
/** Features implemented in this release. Others are refused by validate(). */
public static final Set<AuthentikFeature> AVAILABLE_FEATURES = EnumSet.of(AuthentikFeature.READ, AuthentikFeature.MIRROR);
private static final Pattern APP_KEY_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,48}[a-z0-9]");
private static final Pattern PREFIX_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9]{0,30}");
private String baseUrl;
private String apiToken;
private String appKey;
private String tableNamePrefix = "authentik";
private Set<AuthentikFeature> features = EnumSet.of(AuthentikFeature.READ);
private Boolean readOnly = false;
private String serviceAccountUsername;
private String userScopePathStartsWith;
private List<String> userScopeGroupNames = new ArrayList<>();
private String groupScopeNamePrefix;
private List<String> protectedUsernames = new ArrayList<>();
private String bootstrapOperators = "${env.AUTHENTIK_QBIT_BOOTSTRAP_OPERATORS}";
private List<String> applicationsInScope = new ArrayList<>();
private String mirrorBackendName;
private String recoveryEmailStage;
private String invitationFlow;
private String newUserPath = "users";
private String webhookSecret = "${env.AUTHENTIK_WEBHOOK_SECRET}";
private Integer throttleRequestsPerSecond = 10;
private Integer throttleBurst = 20;
private Integer bulkPacingMillis = 100;
private Integer listCacheTtlSeconds = 30;
private Integer groupCacheTtlSeconds = 60;
private Integer connectTimeoutMillis = 5_000;
private Integer socketTimeoutMillis = 30_000;
private String expectedServerVersion = "2026.8";
private Boolean allowInsecureHttp = false;
/*******************************************************************************
** Collect configuration errors. Never throws; null-safe for a partially
** built QInstance.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(!StringUtils.hasContent(baseUrl))
{
errors.add("baseUrl is required (the Authentik host root, e.g. https://auth.example.com)");
}
else
{
try
{
URI uri = URI.create(baseUrl);
if(uri.getScheme() == null || !(uri.getScheme().equals("https") || uri.getScheme().equals("http")) || uri.getHost() == null)
{
errors.add("baseUrl must be an absolute http(s) URL: " + baseUrl);
}
else if(uri.getScheme().equals("http") && !Boolean.TRUE.equals(allowInsecureHttp) && !isLoopback(uri.getHost()))
{
errors.add("baseUrl must use https (the API token would travel in cleartext); http is allowed only for localhost or with allowInsecureHttp: " + baseUrl);
}
else if(uri.getPath() != null && uri.getPath().contains("/api/v3"))
{
errors.add("baseUrl must be the host root; /api/v3 is appended by the qbit: " + baseUrl);
}
}
catch(IllegalArgumentException e)
{
errors.add("baseUrl is not a valid URL: " + baseUrl);
}
}
if(!StringUtils.hasContent(getResolvedApiToken()))
{
errors.add("apiToken is required (usually ${env.AUTHENTIK_API_TOKEN}) and resolved to an empty value");
}
if(!StringUtils.hasContent(appKey) || !APP_KEY_PATTERN.matcher(appKey).matches())
{
errors.add("appKey is required: lower-case letters, digits and dashes, e.g. my-app-admin");
}
if(tableNamePrefix != null && !tableNamePrefix.isEmpty() && !PREFIX_PATTERN.matcher(tableNamePrefix).matches())
{
errors.add("tableNamePrefix must start with a letter and contain only letters and digits: " + tableNamePrefix);
}
if(StringUtils.hasContent(groupScopeNamePrefix))
{
errors.add("groupScopeNamePrefix is not available in this release of qbit-authentik");
}
Set<AuthentikFeature> requested = features == null ? EnumSet.noneOf(AuthentikFeature.class) : features;
if(!requested.contains(AuthentikFeature.READ))
{
errors.add("feature READ is required");
}
for(AuthentikFeature feature : requested)
{
if(!AVAILABLE_FEATURES.contains(feature))
{
errors.add("feature " + feature + " is not available in this release of qbit-authentik (available: " + AVAILABLE_FEATURES + ")");
}
}
if(requested.contains(AuthentikFeature.MIRROR))
{
if(!StringUtils.hasContent(mirrorBackendName))
{
errors.add("feature MIRROR needs mirrorBackendName (an RDBMS backend of the instance)");
}
else if(qInstance != null && qInstance.getBackends() != null && qInstance.getBackend(mirrorBackendName) == null)
{
errors.add("mirrorBackendName: backend not found: " + mirrorBackendName);
}
}
if((requested.contains(AuthentikFeature.USERS) || requested.contains(AuthentikFeature.CREDENTIALS)) && !StringUtils.hasContent(recoveryEmailStage))
{
errors.add("features USERS and CREDENTIALS need recoveryEmailStage");
}
if(requested.contains(AuthentikFeature.WEBHOOK) && !StringUtils.hasContent(getResolvedWebhookSecret()))
{
errors.add("feature WEBHOOK needs webhookSecret");
}
for(Integer value : new Integer[] { throttleRequestsPerSecond, throttleBurst, listCacheTtlSeconds, groupCacheTtlSeconds, connectTimeoutMillis, socketTimeoutMillis })
{
if(value == null || value < 0)
{
errors.add("throttle, cache and timeout settings must be non-negative numbers");
break;
}
}
if(throttleRequestsPerSecond != null && throttleRequestsPerSecond == 0)
{
errors.add("throttleRequestsPerSecond must be at least 1");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isLoopback(String host)
{
return ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "[::1]".equals(host) || "::1".equals(host));
}
/*******************************************************************************
** Getter for allowInsecureHttp
*******************************************************************************/
public Boolean getAllowInsecureHttp()
{
return (this.allowInsecureHttp);
}
/*******************************************************************************
** Fluent setter for allowInsecureHttp (plain http to a non-loopback host;
** for tests and private networks only)
*******************************************************************************/
public AuthentikQBitConfig withAllowInsecureHttp(Boolean allowInsecureHttp)
{
this.allowInsecureHttp = allowInsecureHttp;
return (this);
}
/*******************************************************************************
** Prefix a component name with the tableNamePrefix (camel-cased join).
*******************************************************************************/
public String applyPrefix(String name)
{
if(!StringUtils.hasContent(tableNamePrefix))
{
return (name);
}
return (tableNamePrefix + Character.toUpperCase(name.charAt(0)) + name.substring(1));
}
/*******************************************************************************
** Name of the API backend the qbit produces.
*******************************************************************************/
@JsonIgnore
public String getApiBackendName()
{
return (applyPrefix("api"));
}
/*******************************************************************************
** The API root: baseUrl without a trailing slash, plus /api/v3.
*******************************************************************************/
@JsonIgnore
public String getApiRoot()
{
String root = baseUrl == null ? "" : baseUrl.trim();
while(root.endsWith("/"))
{
root = root.substring(0, root.length() - 1);
}
return (root + "/api/v3");
}
/*******************************************************************************
** The service account's username: explicit, or qqq-{appKey}-idadmin.
*******************************************************************************/
@JsonIgnore
public String getEffectiveServiceAccountUsername()
{
return (StringUtils.hasContent(serviceAccountUsername) ? serviceAccountUsername : "qqq-" + appKey + "-idadmin");
}
/*******************************************************************************
** The API token with ${env.X} / ${prop.X} references resolved.
*******************************************************************************/
@JsonIgnore
public String getResolvedApiToken()
{
return (resolve(apiToken));
}
/*******************************************************************************
** The webhook secret with references resolved.
*******************************************************************************/
@JsonIgnore
public String getResolvedWebhookSecret()
{
return (resolve(webhookSecret));
}
/*******************************************************************************
** Bootstrap operator emails, resolved and split on commas, lower-cased.
*******************************************************************************/
@JsonIgnore
public Set<String> getResolvedBootstrapOperators()
{
Set<String> rs = new LinkedHashSet<>();
String resolved = resolve(bootstrapOperators);
if(StringUtils.hasContent(resolved))
{
for(String part : resolved.split(","))
{
if(StringUtils.hasContent(part))
{
rs.add(part.trim().toLowerCase());
}
}
}
return (rs);
}
/*******************************************************************************
** Whether a feature is enabled.
*******************************************************************************/
public boolean hasFeature(AuthentikFeature feature)
{
return (features != null && features.contains(feature));
}
/*******************************************************************************
** Resolve ${env.X} style references; a reference to an unset variable
** resolves to null rather than to its literal text.
*******************************************************************************/
static String resolve(String value)
{
if(value == null)
{
return (null);
}
String resolved = new QMetaDataVariableInterpreter().interpret(value);
if(resolved != null && resolved.startsWith("${") && resolved.endsWith("}"))
{
return (null);
}
return (resolved);
}
/*******************************************************************************
** Never print secrets.
*******************************************************************************/
@Override
public String toString()
{
return ("AuthentikQBitConfig{baseUrl=" + baseUrl + ", appKey=" + appKey + ", tableNamePrefix=" + tableNamePrefix
+ ", features=" + features + ", readOnly=" + readOnly + ", apiToken=" + (StringUtils.hasContent(apiToken) ? "******" : "(unset)") + "}");
}
/*******************************************************************************
** Getter for baseUrl
*******************************************************************************/
public String getBaseUrl()
{
return (this.baseUrl);
}
/*******************************************************************************
** Setter for baseUrl
*******************************************************************************/
public void setBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
}
/*******************************************************************************
** Fluent setter for baseUrl
*******************************************************************************/
public AuthentikQBitConfig withBaseUrl(String baseUrl)
{
this.baseUrl = baseUrl;
return (this);
}
/*******************************************************************************
** Getter for apiToken (the raw, possibly ${env.X}, value). Never serialized.
*******************************************************************************/
@JsonIgnore
public String getApiToken()
{
return (this.apiToken);
}
/*******************************************************************************
** Setter for apiToken
*******************************************************************************/
public void setApiToken(String apiToken)
{
this.apiToken = apiToken;
}
/*******************************************************************************
** Fluent setter for apiToken
*******************************************************************************/
public AuthentikQBitConfig withApiToken(String apiToken)
{
this.apiToken = apiToken;
return (this);
}
/*******************************************************************************
** Getter for appKey
*******************************************************************************/
public String getAppKey()
{
return (this.appKey);
}
/*******************************************************************************
** Setter for appKey
*******************************************************************************/
public void setAppKey(String appKey)
{
this.appKey = appKey;
}
/*******************************************************************************
** Fluent setter for appKey
*******************************************************************************/
public AuthentikQBitConfig withAppKey(String appKey)
{
this.appKey = appKey;
return (this);
}
/*******************************************************************************
** Getter for tableNamePrefix
*******************************************************************************/
public String getTableNamePrefix()
{
return (this.tableNamePrefix);
}
/*******************************************************************************
** Setter for tableNamePrefix
*******************************************************************************/
public void setTableNamePrefix(String tableNamePrefix)
{
this.tableNamePrefix = tableNamePrefix;
}
/*******************************************************************************
** Fluent setter for tableNamePrefix
*******************************************************************************/
public AuthentikQBitConfig withTableNamePrefix(String tableNamePrefix)
{
this.tableNamePrefix = tableNamePrefix;
return (this);
}
/*******************************************************************************
** Getter for features
*******************************************************************************/
public Set<AuthentikFeature> getFeatures()
{
return (this.features);
}
/*******************************************************************************
** Setter for features
*******************************************************************************/
public void setFeatures(Set<AuthentikFeature> features)
{
this.features = features;
}
/*******************************************************************************
** Fluent setter for features
*******************************************************************************/
public AuthentikQBitConfig withFeatures(Set<AuthentikFeature> features)
{
this.features = features;
return (this);
}
/*******************************************************************************
** Getter for readOnly
*******************************************************************************/
public Boolean getReadOnly()
{
return (this.readOnly);
}
/*******************************************************************************
** Setter for readOnly
*******************************************************************************/
public void setReadOnly(Boolean readOnly)
{
this.readOnly = readOnly;
}
/*******************************************************************************
** Fluent setter for readOnly
*******************************************************************************/
public AuthentikQBitConfig withReadOnly(Boolean readOnly)
{
this.readOnly = readOnly;
return (this);
}
/*******************************************************************************
** Getter for serviceAccountUsername
*******************************************************************************/
public String getServiceAccountUsername()
{
return (this.serviceAccountUsername);
}
/*******************************************************************************
** Fluent setter for serviceAccountUsername
*******************************************************************************/
public AuthentikQBitConfig withServiceAccountUsername(String serviceAccountUsername)
{
this.serviceAccountUsername = serviceAccountUsername;
return (this);
}
/*******************************************************************************
** Getter for userScopePathStartsWith
*******************************************************************************/
public String getUserScopePathStartsWith()
{
return (this.userScopePathStartsWith);
}
/*******************************************************************************
** Fluent setter for userScopePathStartsWith
*******************************************************************************/
public AuthentikQBitConfig withUserScopePathStartsWith(String userScopePathStartsWith)
{
this.userScopePathStartsWith = userScopePathStartsWith;
return (this);
}
/*******************************************************************************
** Getter for userScopeGroupNames
*******************************************************************************/
public List<String> getUserScopeGroupNames()
{
return (this.userScopeGroupNames);
}
/*******************************************************************************
** Fluent setter for userScopeGroupNames
*******************************************************************************/
public AuthentikQBitConfig withUserScopeGroupNames(List<String> userScopeGroupNames)
{
this.userScopeGroupNames = userScopeGroupNames;
return (this);
}
/*******************************************************************************
** Getter for groupScopeNamePrefix
*******************************************************************************/
public String getGroupScopeNamePrefix()
{
return (this.groupScopeNamePrefix);
}
/*******************************************************************************
** Fluent setter for groupScopeNamePrefix
*******************************************************************************/
public AuthentikQBitConfig withGroupScopeNamePrefix(String groupScopeNamePrefix)
{
this.groupScopeNamePrefix = groupScopeNamePrefix;
return (this);
}
/*******************************************************************************
** Getter for protectedUsernames
*******************************************************************************/
public List<String> getProtectedUsernames()
{
return (this.protectedUsernames);
}
/*******************************************************************************
** Fluent setter for protectedUsernames
*******************************************************************************/
public AuthentikQBitConfig withProtectedUsernames(List<String> protectedUsernames)
{
this.protectedUsernames = protectedUsernames;
return (this);
}
/*******************************************************************************
** Getter for bootstrapOperators (raw value)
*******************************************************************************/
public String getBootstrapOperators()
{
return (this.bootstrapOperators);
}
/*******************************************************************************
** Fluent setter for bootstrapOperators (comma-separated emails or ${env.X})
*******************************************************************************/
public AuthentikQBitConfig withBootstrapOperators(String bootstrapOperators)
{
this.bootstrapOperators = bootstrapOperators;
return (this);
}
/*******************************************************************************
** Getter for applicationsInScope
*******************************************************************************/
public List<String> getApplicationsInScope()
{
return (this.applicationsInScope);
}
/*******************************************************************************
** Fluent setter for applicationsInScope. Explicit slugs are required:
** Authentik lists to a non-superuser only the applications that user can
** itself access, so the qbit cannot discover them (phase 0 finding).
*******************************************************************************/
public AuthentikQBitConfig withApplicationsInScope(List<String> applicationsInScope)
{
this.applicationsInScope = applicationsInScope;
return (this);
}
/*******************************************************************************
** Getter for mirrorBackendName
*******************************************************************************/
public String getMirrorBackendName()
{
return (this.mirrorBackendName);
}
/*******************************************************************************
** Fluent setter for mirrorBackendName
*******************************************************************************/
public AuthentikQBitConfig withMirrorBackendName(String mirrorBackendName)
{
this.mirrorBackendName = mirrorBackendName;
return (this);
}
/*******************************************************************************
** Getter for recoveryEmailStage
*******************************************************************************/
public String getRecoveryEmailStage()
{
return (this.recoveryEmailStage);
}
/*******************************************************************************
** Fluent setter for recoveryEmailStage
*******************************************************************************/
public AuthentikQBitConfig withRecoveryEmailStage(String recoveryEmailStage)
{
this.recoveryEmailStage = recoveryEmailStage;
return (this);
}
/*******************************************************************************
** Getter for invitationFlow
*******************************************************************************/
public String getInvitationFlow()
{
return (this.invitationFlow);
}
/*******************************************************************************
** Fluent setter for invitationFlow
*******************************************************************************/
public AuthentikQBitConfig withInvitationFlow(String invitationFlow)
{
this.invitationFlow = invitationFlow;
return (this);
}
/*******************************************************************************
** Getter for newUserPath
*******************************************************************************/
public String getNewUserPath()
{
return (this.newUserPath);
}
/*******************************************************************************
** Fluent setter for newUserPath
*******************************************************************************/
public AuthentikQBitConfig withNewUserPath(String newUserPath)
{
this.newUserPath = newUserPath;
return (this);
}
/*******************************************************************************
** Getter for webhookSecret (raw value). Never serialized.
*******************************************************************************/
@JsonIgnore
public String getWebhookSecret()
{
return (this.webhookSecret);
}
/*******************************************************************************
** Fluent setter for webhookSecret
*******************************************************************************/
public AuthentikQBitConfig withWebhookSecret(String webhookSecret)
{
this.webhookSecret = webhookSecret;
return (this);
}
/*******************************************************************************
** Getter for throttleRequestsPerSecond
*******************************************************************************/
public Integer getThrottleRequestsPerSecond()
{
return (this.throttleRequestsPerSecond);
}
/*******************************************************************************
** Fluent setter for throttleRequestsPerSecond
*******************************************************************************/
public AuthentikQBitConfig withThrottleRequestsPerSecond(Integer throttleRequestsPerSecond)
{
this.throttleRequestsPerSecond = throttleRequestsPerSecond;
return (this);
}
/*******************************************************************************
** Getter for throttleBurst
*******************************************************************************/
public Integer getThrottleBurst()
{
return (this.throttleBurst);
}
/*******************************************************************************
** Fluent setter for throttleBurst
*******************************************************************************/
public AuthentikQBitConfig withThrottleBurst(Integer throttleBurst)
{
this.throttleBurst = throttleBurst;
return (this);
}
/*******************************************************************************
** Getter for bulkPacingMillis
*******************************************************************************/
public Integer getBulkPacingMillis()
{
return (this.bulkPacingMillis);
}
/*******************************************************************************
** Fluent setter for bulkPacingMillis
*******************************************************************************/
public AuthentikQBitConfig withBulkPacingMillis(Integer bulkPacingMillis)
{
this.bulkPacingMillis = bulkPacingMillis;
return (this);
}
/*******************************************************************************
** Getter for listCacheTtlSeconds
*******************************************************************************/
public Integer getListCacheTtlSeconds()
{
return (this.listCacheTtlSeconds);
}
/*******************************************************************************
** Fluent setter for listCacheTtlSeconds (0 disables the list cache)
*******************************************************************************/
public AuthentikQBitConfig withListCacheTtlSeconds(Integer listCacheTtlSeconds)
{
this.listCacheTtlSeconds = listCacheTtlSeconds;
return (this);
}
/*******************************************************************************
** Getter for groupCacheTtlSeconds
*******************************************************************************/
public Integer getGroupCacheTtlSeconds()
{
return (this.groupCacheTtlSeconds);
}
/*******************************************************************************
** Fluent setter for groupCacheTtlSeconds (0 disables the group cache)
*******************************************************************************/
public AuthentikQBitConfig withGroupCacheTtlSeconds(Integer groupCacheTtlSeconds)
{
this.groupCacheTtlSeconds = groupCacheTtlSeconds;
return (this);
}
/*******************************************************************************
** Getter for connectTimeoutMillis
*******************************************************************************/
public Integer getConnectTimeoutMillis()
{
return (this.connectTimeoutMillis);
}
/*******************************************************************************
** Fluent setter for connectTimeoutMillis
*******************************************************************************/
public AuthentikQBitConfig withConnectTimeoutMillis(Integer connectTimeoutMillis)
{
this.connectTimeoutMillis = connectTimeoutMillis;
return (this);
}
/*******************************************************************************
** Getter for socketTimeoutMillis
*******************************************************************************/
public Integer getSocketTimeoutMillis()
{
return (this.socketTimeoutMillis);
}
/*******************************************************************************
** Fluent setter for socketTimeoutMillis
*******************************************************************************/
public AuthentikQBitConfig withSocketTimeoutMillis(Integer socketTimeoutMillis)
{
this.socketTimeoutMillis = socketTimeoutMillis;
return (this);
}
/*******************************************************************************
** Getter for expectedServerVersion
*******************************************************************************/
public String getExpectedServerVersion()
{
return (this.expectedServerVersion);
}
/*******************************************************************************
** Fluent setter for expectedServerVersion (major.minor, e.g. 2026.8)
*******************************************************************************/
public AuthentikQBitConfig withExpectedServerVersion(String expectedServerVersion)
{
this.expectedServerVersion = expectedServerVersion;
return (this);
}
}