release: promote develop to release/0.1 (0.1.0-RC.9) #64

Merged
james.maes merged 10 commits from develop into release/0.1 2026-11-06 15:35:42 +00:00
16 changed files with 101 additions and 109 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main
secrets: inherit
-25
View File
@@ -1,25 +0,0 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
with:
runner: linux-small
docker-runner: container-build
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
-72
View File
@@ -1,72 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original release run id (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
permissions:
contents: read
jobs:
release-rc:
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
permissions:
contents: write
actions: read
pull-requests: write
if: github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ inputs.merge_sha || '' }}
bump_sha: ${{ inputs.bump_sha || '' }}
branch: ${{ inputs.branch || '' }}
version: ${{ inputs.version || '' }}
secrets:
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel
## [Unreleased]
- Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval.
- **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255).
- Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: <permission>`, `malformed <attribute>` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was.
- Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider").
@@ -1407,6 +1407,10 @@ public class AuthentikAdmin
}
plan.withChange(null, name + (parentPks.isEmpty() ? "" : ", under " + request.str("parents")) + (permissions.isEmpty() ? "" : ", permissions " + String.join(", ", permissions)));
if(config.getTwoPersonApproval() && !request.bool("approved") && addsIdentityPermission(permissions, List.of()))
{
plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("granting authentik.* needs a second person");
}
return (plan);
}
@@ -161,7 +161,7 @@ public class SchemaDriftTest
{
return (surfacePath);
}
String regex = surfacePath.replace("{key}", "\\{[^/]+\\}");
String regex = java.util.regex.Pattern.quote(surfacePath).replace("{key}", "\\E\\{[^/]+\\}\\Q");
Iterator<Map.Entry<String, JsonNode>> fields = paths.fields();
while(fields.hasNext())
{
@@ -541,6 +541,34 @@ class AuthentikAdminTest
/*******************************************************************************
** Creation-time identity grants obey the same second-person gate as edits.
*******************************************************************************/
@Test
void creatingGroupWithIdentityPermissionsNeedsASecondPerson() throws QException
{
AdminRequest request = AdminRequest.of(AdminAction.CREATE_GROUP, "", "synthetic approval check")
.with("name", "synthetic-identity-readers").with("permissions", "authentik.users.read");
AuthentikAdmin.AdminResult queued = run(request);
assertThat(queued.ok()).as(queued.message()).isTrue();
assertThat(queued.pending()).isTrue();
assertThat(t.writes()).isEmpty();
List<QRecord> approvals = rows(AuthentikLocalTables.APPROVAL_REQUEST);
assertThat(approvals).hasSize(1);
java.io.Serializable id = approvals.get(0).getValue("id");
assertThat(t.admin().approve(id).ok()).isFalse();
assertThat(t.writes()).isEmpty();
t.loginAs("second@example.test", IdentityPermission.APPROVE, IdentityPermission.GROUPS_MANAGE);
AuthentikAdmin.AdminResult approved = t.admin().approve(id);
assertThat(approved.ok()).as(approved.message()).isTrue();
assertThat(t.authentik.groupByName("synthetic-identity-readers").getJSONObject("attributes")
.getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.users.read");
assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValueString("status")).isEqualTo("APPROVED");
assertThat(t.admin().approve(id).ok()).isFalse();
}
/*******************************************************************************
** Re-parenting: loops and superuser parents are refused.
*******************************************************************************/
@@ -255,9 +255,36 @@ public final class AuthentikTestStack
TestApi admin = admin();
try
{
long[] nextRetry = { System.currentTimeMillis() + 60_000 };
waitFor(Duration.ofMinutes(8), "all three kof22 blueprints applied", () ->
isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT)
{
////////////////////////////////////////////////////////////////////
// built-in blueprints may race our custom dependencies on first //
// boot, leaving one of ours in "error" or never applied //
// ("unknown"); re-apply it (throttled). //
////////////////////////////////////////////////////////////////////
if(System.currentTimeMillis() >= nextRetry[0])
{
nextRetry[0] = System.currentTimeMillis() + 30_000;
for(String name : List.of(INFRA_BLUEPRINT, BOOTSTRAP_BLUEPRINT, TOKEN_BLUEPRINT))
{
try
{
JSONObject current = blueprint(name);
if(current != null && List.of("error", "unknown").contains(current.optString("status")))
{
admin.post("/managed/blueprints/" + current.getString("pk") + "/apply/", new JSONObject());
}
}
catch(Exception e)
{
// Bootstrap authentication and built-in dependencies settle after HTTP readiness.
}
}
}
return (isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT)
&& admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null);
});
}
catch(IllegalStateException e)
{
@@ -278,8 +305,16 @@ public final class AuthentikTestStack
}
try
{
worker.getLogs().lines().filter(line -> line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail")))
worker.getLogs().lines().filter(line -> (line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail")))
|| line.contains("\"level\": \"error\"") || line.contains("Traceback"))
.filter(line -> !line.contains("clear_failed_blueprints"))
.limit(60).forEach(line -> System.err.println("worker: " + line));
////////////////////////////////////////////////////////////////
// blueprints re-applied through the API run in the server //
////////////////////////////////////////////////////////////////
server.getLogs().lines().filter(line -> line.contains("\"level\": \"error\"") || line.contains("\"level\": \"warning\"") || line.contains("Traceback"))
.filter(line -> !line.contains("Base URL is not configured"))
.limit(60).forEach(line -> System.err.println("server: " + line));
}
catch(RuntimeException unavailable)
{
@@ -174,7 +174,7 @@ class InvitationEnrollmentContractIT
}
assertThat(used).isNotNull();
assertThat(used.getJSONObject("user").optInt("pk", -1)).isNotEqualTo(userPk);
var config = assignment == null ? new com.kingsrook.qbits.authentik.AuthentikQBitConfig().withBaseUrl(stack.baseUrl())
var config = assignment == null ? new com.kingsrook.qbits.authentik.AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true)
.withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin").withListCacheTtlSeconds(0) : assignment.config;
var instance = assignment == null ? com.kingsrook.qbits.authentik.AuthentikTestSupport.produce(config) : assignment.instance;
com.kingsrook.qqq.backend.core.context.QContext.init(instance,
@@ -265,7 +265,7 @@ class InvitationEnrollmentContractIT
String actor = create(admin, "/core/users/", new JSONObject().put("username", owner + "-operator").put("name", "Invitation operator")
.put("email", owner + "-operator@example.test").put("type", "internal").put("path", "users/invitation-contract"), cleanup);
assertThat(admin.post("/core/groups/" + admins + "/add_user/", new JSONObject().put("pk", Integer.parseInt(actor))).status()).isEqualTo(204);
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
.withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withInvitationFlow(owner).withLocalBackendName("memory")
.withRecoveryEmailStage("kof22-recovery-email")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP));
@@ -96,7 +96,7 @@ class Phase1ReadContractIT
void beforeEach() throws QException
{
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true)
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG))
@@ -126,7 +126,7 @@ class QbitReadContractIT
void beforeEach() throws QException
{
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true)
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withListCacheTtlSeconds(0);
@@ -155,7 +155,7 @@ class QbitWriteContractIT
{
MemoryRecordStore.getInstance().reset();
config = new AuthentikQBitConfig()
.withBaseUrl(stack.baseUrl())
.withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true)
.withApiToken(stack.serviceAccountToken())
.withAppKey("kof22-admin")
.withListCacheTtlSeconds(0)
@@ -89,7 +89,7 @@ class SchemaDriftIT
for(java.util.Iterator<String> it = paths.fieldNames(); it.hasNext(); )
{
String candidate = it.next();
if(candidate.equals(path) || candidate.matches(path.replace("{key}", "\\{[^/]+\\}")))
if(candidate.equals(path) || candidate.matches(java.util.regex.Pattern.quote(path).replace("{key}", "\\E\\{[^/]+\\}\\Q")))
{
return (SchemaDriftTest.parameterNames(schema, paths.get(candidate).get(operation.method())));
}
@@ -579,7 +579,7 @@ class TokenContractIT
private void configureQbit(int owner, String subject) throws Exception
{
MemoryRecordStore.getInstance().reset();
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin")
.withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withBulkPacingMillis(0).withLocalBackendName("memory")
.withRecoveryEmailStage("kof22-recovery-email")
.withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.TOKENS))
@@ -16,6 +16,7 @@
package com.kingsrook.qbits.authentik.sync;
import java.util.Set;
import java.util.UUID;
import javax.sql.DataSource;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
@@ -27,6 +28,7 @@ import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
import org.postgresql.ds.PGSimpleDataSource;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.PostgreSQLContainer;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.PortBinding;
@@ -39,6 +41,7 @@ import com.github.dockerjava.api.model.Ports;
@ExtendWith(RequiresDockerCondition.class)
class AuthentikMirrorPostgresIT extends AuthentikMirrorSqlTest
{
private static final Set<String> LOCAL_DOCKER_HOSTS = Set.of("localhost", "127.0.0.1");
private static PostgreSQLContainer<?> postgres;
private String schema;
@@ -55,7 +58,11 @@ class AuthentikMirrorPostgresIT extends AuthentikMirrorSqlTest
.withCreateContainerCmdModifier(command ->
{
command.withName(owner);
command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432)));
// Loopback-only only when the daemon is local; a remote daemon (CI) is reached via its host address.
if(LOCAL_DOCKER_HOSTS.contains(DockerClientFactory.instance().dockerHostIpAddress()))
{
command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432)));
}
});
try
{
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.authentik.sync;
import java.sql.SQLException;
import java.util.Set;
import java.util.UUID;
import javax.sql.DataSource;
import com.kingsrook.qbits.authentik.RequiresDockerCondition;
@@ -24,6 +25,7 @@ import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
import org.postgresql.ds.PGSimpleDataSource;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.PostgreSQLContainer;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.PortBinding;
@@ -36,6 +38,7 @@ import com.github.dockerjava.api.model.Ports;
@ExtendWith(RequiresDockerCondition.class)
class AuthentikWebhookInboxPostgresIT extends AuthentikWebhookInboxTest
{
private static final Set<String> LOCAL_DOCKER_HOSTS = Set.of("localhost", "127.0.0.1");
private static PostgreSQLContainer<?> postgres;
private String schema;
@@ -53,7 +56,11 @@ class AuthentikWebhookInboxPostgresIT extends AuthentikWebhookInboxTest
.withCreateContainerCmdModifier(command ->
{
command.withName(owner);
command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432)));
// Loopback-only only when the daemon is local; a remote daemon (CI) is reached via its host address.
if(LOCAL_DOCKER_HOSTS.contains(DockerClientFactory.instance().dockerHostIpAddress()))
{
command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432)));
}
});
try
{