QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 38s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 38s
qbit release candidate / cut-rc (pull_request) Failing after 18s
Release candidate / release-rc (pull_request) Failing after 18s
qbit release candidate / tag-and-release (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 15m39s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 24s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 1h14m34s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 10s
Shared Java Maven CI with Docker / verify (push) Failing after 17m19s
Shared Java Maven CI with Docker / gate (push) Failing after 24s
qrun-release / release (push) Failing after 1h16m30s
qrun-release / postpublish (push) Skipped
Add user and group QQQ application-role access editing, bounded catalogs, webhook inbox and SQL mirrors, refreshed session permissions, and deferred invitation assignments. Include the isolated local review host, browser acceptance scripts, documentation and the token-widget column mapping fix. User approved this RC checkpoint. Full 1.0 gates remain documented, including the schema-drift matcher and PostgreSQL integration errors from the full gate. Companion UI: QRun-IO/qqq-frontend-next#205. Co-Authored-By: GPT-6 <noreply@openai.com>
172 lines
9.3 KiB
Java
172 lines
9.3 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.sync;
|
|
|
|
import java.time.Duration;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import com.kingsrook.qbits.authentik.AuthentikFeature;
|
|
import com.kingsrook.qbits.authentik.AuthentikQBitConfig;
|
|
import com.kingsrook.qbits.authentik.AuthentikRuntime;
|
|
import com.kingsrook.qbits.authentik.client.AuthentikActionUtil;
|
|
import com.kingsrook.qbits.authentik.security.IdentityPermission;
|
|
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
|
|
|
/*******************************************************************************
|
|
** Native background processes; database ownership, not scheduler locality,
|
|
** fences publication. The QBit producer registers these when MIRROR is enabled.
|
|
*******************************************************************************/
|
|
public class AuthentikSyncProcesses implements BackendStep
|
|
{
|
|
public static final String INBOX = "syncInbox";
|
|
public static final String INCREMENTAL = "syncIncremental";
|
|
public static final String FULL = "syncFull";
|
|
public static final String RETENTION = "syncRetention";
|
|
|
|
/***************************************************************************
|
|
** No threads or extra scheduler are created. Ambiguous scheduler selection
|
|
** is a configuration error rather than silently scheduling on another host.
|
|
***************************************************************************/
|
|
public static List<QProcessMetaData> processes(QInstance instance, AuthentikQBitConfig config)
|
|
{
|
|
if(!config.hasFeature(AuthentikFeature.MIRROR))
|
|
{
|
|
return (List.of());
|
|
}
|
|
String scheduler = config.getSchedulerName();
|
|
if(scheduler == null && instance.getSchedulers().size() == 1)
|
|
{
|
|
scheduler = instance.getSchedulers().keySet().iterator().next();
|
|
}
|
|
if(scheduler == null || !instance.getSchedulers().containsKey(scheduler))
|
|
{
|
|
throw (new IllegalArgumentException("Mirror synchronization requires an unambiguous registered scheduler."));
|
|
}
|
|
if(!instance.getSchedulers().get(scheduler).supportsCronSchedules())
|
|
{
|
|
throw (new IllegalArgumentException("Mirror synchronization requires a scheduler supporting the nightly cron schedule."));
|
|
}
|
|
List<QProcessMetaData> processes = new ArrayList<>();
|
|
// Reconcile before the fifteen-minute authorization snapshot expires.
|
|
// The previous scan-start watermark stays authoritative until commit;
|
|
// starting only at its expiry would deny access during every next scan.
|
|
processes.add(process(config, INCREMENTAL, "Reconcile identity changes", new QScheduleMetaData()
|
|
.withSchedulerName(scheduler).withRepeatSeconds(300).withInitialDelaySeconds(1)));
|
|
processes.add(process(config, FULL, "Reconcile complete identity directory", new QScheduleMetaData()
|
|
.withSchedulerName(scheduler).withCronExpression("0 0 2 * * ?").withCronTimeZoneId("UTC")));
|
|
processes.add(process(config, RETENTION, "Expire identity synchronization history", new QScheduleMetaData()
|
|
.withSchedulerName(scheduler).withRepeatSeconds(86400).withInitialDelaySeconds(3600)));
|
|
if(config.hasFeature(AuthentikFeature.WEBHOOK))
|
|
{
|
|
var inbox = process(config, INBOX, "Process identity change events", new QScheduleMetaData()
|
|
.withSchedulerName(scheduler).withRepeatSeconds(2).withInitialDelaySeconds(2));
|
|
if(com.kingsrook.qqq.backend.core.utils.StringUtils.hasContent(config.getEsbDestinationName()))
|
|
{
|
|
com.kingsrook.qbits.authentik.esb.AuthentikEsbDelivery.attach(instance, config, inbox);
|
|
}
|
|
processes.add(inbox);
|
|
}
|
|
return (List.copyOf(processes));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Inputs contain routing names only, never credentials or event payloads.
|
|
***************************************************************************/
|
|
private static QProcessMetaData process(AuthentikQBitConfig config, String operation, String label, QScheduleMetaData schedule)
|
|
{
|
|
return (new QProcessMetaData().withName(config.applyPrefix(operation)).withLabel(label).withIsHidden(true)
|
|
.withPermissionRules(IdentityPermission.ADMIN.rules()).withSchedule(schedule)
|
|
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(AuthentikSyncProcesses.class))
|
|
.withInputData(new QFunctionInputMetaData()
|
|
.withField(new QFieldMetaData("backendName", QFieldType.STRING).withDefaultValue(config.getApiBackendName()))
|
|
.withField(new QFieldMetaData("operation", QFieldType.STRING).withDefaultValue(operation)))));
|
|
}
|
|
|
|
/***************************************************************************
|
|
** Only the host's internal scheduler/ESB context may run directory jobs.
|
|
** Never elevate an interactive caller. Feature disablement applies live.
|
|
***************************************************************************/
|
|
@Override
|
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
|
{
|
|
if(!(QContext.getQSession() instanceof QSystemUserSession))
|
|
{
|
|
throw (new QPermissionDeniedException("Directory synchronization requires the internal session."));
|
|
}
|
|
var config = AuthentikRuntime.forBackend(input.getValueString("backendName")).getConfig();
|
|
String operation = input.getValueString("operation");
|
|
if(!config.hasFeature(AuthentikFeature.MIRROR) || (INBOX.equals(operation) && !config.hasFeature(AuthentikFeature.WEBHOOK)))
|
|
{
|
|
throw (new QPermissionDeniedException("Directory synchronization feature is disabled."));
|
|
}
|
|
try
|
|
{
|
|
// The refreshing OAuth adapter reads committed mirror permissions on
|
|
// every request. Publication itself is its durable reevaluation signal;
|
|
// there are no node-local session caches or separate markers to update.
|
|
AuthentikMirrorWorker.Publication publication = (snapshot, transaction) -> { };
|
|
switch(operation == null ? "" : operation)
|
|
{
|
|
case INBOX ->
|
|
{
|
|
var result = AuthentikApplyChangeEvents.forHost(config, publication).runBatch();
|
|
output.addValue("succeeded", result.succeeded());
|
|
output.addValue("retriedOrFailed", result.retriedOrFailed());
|
|
output.addValue("lostLease", result.lostLease());
|
|
output.addValue("deferred", result.deferred());
|
|
}
|
|
case INCREMENTAL, FULL ->
|
|
{
|
|
var worker = new AuthentikMirrorWorker(config, AuthentikActionUtil.forBackend(config.getApiBackendName()));
|
|
boolean published = FULL.equals(operation) ? worker.fullRefresh(publication, null, null) : worker.incrementalRefresh(publication);
|
|
output.addValue("published", published);
|
|
}
|
|
case RETENTION ->
|
|
{
|
|
output.addValue("eventsPurged", new AuthentikWebhookInbox(config, Duration.ofMinutes(15)).purge());
|
|
output.addValue("bindingsPurged", new AuthentikSessionBindings(config).purgeExpired());
|
|
output.addValue("invitationIntentsPurged", new AuthentikInvitationAssignments(config).purgeSettled());
|
|
}
|
|
default -> throw (new QException("Unknown directory synchronization operation."));
|
|
}
|
|
}
|
|
catch(Exception failure)
|
|
{
|
|
if(failure instanceof InterruptedException)
|
|
{
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
// Process persistence/logging must not capture upstream payloads or SQL.
|
|
throw (new QException("Directory synchronization did not complete."));
|
|
}
|
|
}
|
|
}
|