From 5b896ee2a277c832da25df7461f6cb6bf698d4e6 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Tue, 4 Aug 2026 21:23:01 +0200
Subject: [PATCH] feat(pki-server): expose security control administration
Expose principals, scoped assignments, direct grants, approvals,
break-glass access, disclosure policy and one-time capabilities through
the unified administrative HTTPS operation gateway.
Preserve default-deny authorization, immutable PKI authority, PII
boundaries and uncertainty-aware secret delivery.
---
docs/pki-server-admin-https.md | 22 ++
.../pki/server/AdministrativeOperation.java | 59 +++
.../pki/server/BreakGlassService.java | 70 +++-
.../pki/server/DisclosureService.java | 73 +++-
.../server/OperationSecurityDescriptors.java | 145 ++++++-
.../java/zeroecho/pki/server/Permission.java | 6 +-
.../pki/server/RoleTemplateCatalog.java | 14 +-
.../pki/server/ServerControlOperation.java | 280 +++++++++++++
.../ServerControlOperationExecutor.java | 370 ++++++++++++++++++
.../server/ServerControlOperationOutcome.java | 80 ++++
.../pki/server/ServerControlStore.java | 108 ++++-
.../pki/server/ServerOperationGateway.java | 146 ++++++-
.../pki/server/ServerRealmContext.java | 3 +-
.../pki/server/http/AdminHttpHandler.java | 33 +-
.../pki/server/http/HttpOperationCodec.java | 270 ++++++++++++-
.../pki/server/http/HttpResponses.java | 39 +-
.../server/security/role-templates-v1.json | 6 +-
.../server/ControlApprovalDisclosureTest.java | 43 +-
.../pki/server/PkiHttpsServerTest.java | 13 +
.../ServerControlOperationExecutorTest.java | 238 +++++++++++
.../server/ServerOperationGatewayTest.java | 4 +-
.../HttpServerControlOperationCodecTest.java | 98 +++++
22 files changed, 2027 insertions(+), 93 deletions(-)
create mode 100644 pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java
create mode 100644 pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java
create mode 100644 pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java
create mode 100644 pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java
create mode 100644 pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java
create mode 100644 pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java
diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md
index 6d2a554..3ce24fa 100644
--- a/docs/pki-server-admin-https.md
+++ b/docs/pki-server-admin-https.md
@@ -45,3 +45,25 @@ Shutdown first removes readiness and admission, stops listener acceptance, drain
## Request correlation
Clients may supply `X-ZeroEcho-Request-Id` using 16–128 ASCII letters, digits, underscore, or hyphen. Otherwise the server creates a random opaque identifier. It is correlation metadata—not an idempotency key, object identity, approval, or capability token.
+
+## Security-control administration
+
+The operation catalog is the single administrative namespace for both existing
+PKI operations and closed `SERVER_CONTROL_OPERATION` requests. Subject to scoped
+authorization, it exposes principal lifecycle, immutable role-template
+inspection, scoped assignments and grants, approval decisions, break-glass
+lifecycle, disclosure policy, and auditor projections. Lists are explicitly
+paged; revocation disables a control record without erasing its history.
+
+High-risk changes use a separately created approval request committed to the
+exact typed target operation and scope. An approval decision never executes the
+target. The later ordinary operation request supplies the approval ID and the
+gateway claims and completes it exactly once.
+
+`security.disclosure.capability.issue` is the only one-time-sensitive response.
+The durable store receives the token commitment before the raw 256-bit token is
+returned. Its HTTPS response additionally carries `Cache-Control: no-store`,
+`Pragma: no-cache`, and `Referrer-Policy: no-referrer`. The token is absent from
+catalogs, inspection results and audit details. A response-write failure is
+classified as delivery-unknown; the server neither regenerates nor retries the
+token automatically.
diff --git a/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java
new file mode 100644
index 0000000..0877b52
--- /dev/null
+++ b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java
@@ -0,0 +1,59 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server;
+
+import java.util.Objects;
+
+import zeroecho.pki.application.PkiOperation;
+
+/** One closed administrative namespace spanning PKI and server-control families. */
+public sealed interface AdministrativeOperation permits AdministrativeOperation.Pki,
+ AdministrativeOperation.Control {
+ /** @return stable operation identity */
+ String name();
+
+ /** Existing transport-neutral PKI operation family. */
+ record Pki(PkiOperation operation) implements AdministrativeOperation {
+ /** Validates the typed PKI operation. */
+ public Pki { Objects.requireNonNull(operation, "operation"); }
+ @Override public String name() { return operation.name(); }
+ }
+
+ /** Server security-control operation family. */
+ record Control(ServerControlOperation operation) implements AdministrativeOperation {
+ /** Validates the typed control operation. */
+ public Control { Objects.requireNonNull(operation, "operation"); }
+ @Override public String name() { return operation.name(); }
+ }
+}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java
index 10d11c9..9cc6128 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java
@@ -43,17 +43,18 @@ import java.util.Optional;
/** Durable, scoped, mandatory-expiry emergency authorization service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
- "PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" })
+ "PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel",
+ "PMD.ExcessiveParameterList" })
public final class BreakGlassService {
/** Break-glass lifecycle states. */
public enum State {
- ACTIVE(1), REVOKED(2), EXPIRED(3);
+ ACTIVE(1), REVOKED(2), EXPIRED(3), PENDING(4);
private final int code;
State(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves one stable persistence code. */
public static State fromCode(int code) {
- return switch (code) { case 1 -> ACTIVE; case 2 -> REVOKED; case 3 -> EXPIRED;
+ return switch (code) { case 1 -> ACTIVE; case 2 -> REVOKED; case 3 -> EXPIRED; case 4 -> PENDING;
default -> throw new IllegalArgumentException("Unknown break-glass state code"); };
}
}
@@ -74,7 +75,7 @@ public final class BreakGlassService {
* @param auditCommitment stable safe commitment
*/
public record Record(String breakGlassId, String principalId, Permission.Grant grant, String reason,
- String issuerPrincipalId, Optional approvalId, Instant createdAt, Instant activatedAt,
+ String issuerPrincipalId, Optional approvalId, Instant createdAt, Optional activatedAt,
Instant expiresAt, State state, String auditCommitment) {
/** Validates the strict time-limited scoped record. */
public Record {
@@ -91,17 +92,30 @@ public final class BreakGlassService {
}
approvalId = Objects.requireNonNull(approvalId, "approvalId");
Objects.requireNonNull(createdAt, "createdAt");
- Objects.requireNonNull(activatedAt, "activatedAt");
+ activatedAt = Objects.requireNonNull(activatedAt, "activatedAt");
Objects.requireNonNull(expiresAt, "expiresAt");
- if (activatedAt.isBefore(createdAt) || !expiresAt.isAfter(activatedAt)
- || Duration.between(activatedAt, expiresAt).compareTo(Duration.ofHours(24)) > 0) {
+ Instant lifetimeStart = activatedAt.orElse(createdAt);
+ if (activatedAt.filter(value -> value.isBefore(createdAt)).isPresent() || !expiresAt.isAfter(lifetimeStart)
+ || Duration.between(lifetimeStart, expiresAt).compareTo(Duration.ofHours(24)) > 0) {
throw new IllegalArgumentException("Break-glass lifetime is invalid");
}
Objects.requireNonNull(state, "state");
+ if (state == State.PENDING && activatedAt.isPresent()
+ || state == State.ACTIVE && activatedAt.isEmpty()) {
+ throw new IllegalArgumentException("Break-glass activation state is invalid");
+ }
if (auditCommitment == null || !auditCommitment.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Break-glass audit commitment is invalid");
}
}
+
+ /** Creates an activated record for existing embedded callers. */
+ public Record(String breakGlassId, String principalId, Permission.Grant grant, String reason,
+ String issuerPrincipalId, Optional approvalId, Instant createdAt, Instant activatedAt,
+ Instant expiresAt, State state, String auditCommitment) {
+ this(breakGlassId, principalId, grant, reason, issuerPrincipalId, approvalId, createdAt,
+ Optional.of(activatedAt), expiresAt, state, auditCommitment);
+ }
}
/** Active grants plus their identities for authorization and use auditing. */
@@ -124,21 +138,38 @@ public final class BreakGlassService {
this.audit = new SafeAudit(clock, auditSink);
}
- /** Creates and immediately activates one independently issued emergency grant. */
- public synchronized Record activate(String breakGlassId, String principalId, Permission.Grant grant, String reason,
+ /** Creates one durable pending emergency grant without activating it. */
+ public synchronized Record create(String breakGlassId, String principalId, Permission.Grant grant, String reason,
String issuerPrincipalId, Optional approvalId, Instant expiresAt) {
- store.requirePrincipal(principalId);
- store.requirePrincipal(issuerPrincipalId);
+ store.requirePrincipal(principalId); store.requirePrincipal(issuerPrincipalId);
+ requireExecutingApproval(approvalId, ServerControlOperation.CreateBreakGlass.CREATE, grant.scope());
Instant now = clock.instant();
String commitment = ApprovalService.digest(breakGlassId + '|' + principalId + '|' + grant.grantId()
+ '|' + issuerPrincipalId + '|' + now + '|' + expiresAt);
Record record = new Record(breakGlassId, principalId, grant, reason, issuerPrincipalId, approvalId,
- now, now, expiresAt, State.ACTIVE, commitment);
+ now, Optional.empty(), expiresAt, State.PENDING, commitment);
store.createBreakGlass(record);
- audit.record("BREAK_GLASS_ACTIVATE", issuerPrincipalId, Optional.empty(), Map.of("state", "ACTIVE"));
+ audit.record("BREAK_GLASS_CREATE", issuerPrincipalId, Optional.empty(), Map.of("state", "PENDING"));
return record;
}
+ /** Activates one independently approved pending emergency grant. */
+ public synchronized Record activatePending(String breakGlassId, String actorPrincipalId,
+ Optional approvalId) {
+ Record current = requireCurrent(breakGlassId);
+ if (current.state() != State.PENDING) {
+ throw new IllegalStateException("Break-glass record is not approved for activation");
+ }
+ requireExecutingApproval(approvalId, ServerControlOperation.ActivateBreakGlass.ACTIVATE,
+ current.grant().scope());
+ Record active = new Record(current.breakGlassId(), current.principalId(), current.grant(), current.reason(),
+ current.issuerPrincipalId(), approvalId, current.createdAt(), Optional.of(clock.instant()),
+ current.expiresAt(), State.ACTIVE, current.auditCommitment());
+ store.replaceBreakGlass(current, active);
+ audit.record("BREAK_GLASS_ACTIVATE", actorPrincipalId, Optional.empty(), Map.of("state", "ACTIVE"));
+ return active;
+ }
+
/** Revokes an active grant. */
public synchronized Record revoke(String breakGlassId, String actorPrincipalId) {
Record current = requireCurrent(breakGlassId);
@@ -152,7 +183,8 @@ public final class BreakGlassService {
/** Returns a current record, durably applying expiry when observed. */
public synchronized Record requireCurrent(String breakGlassId) {
Record current = store.requireBreakGlass(breakGlassId);
- if (current.state() == State.ACTIVE && !clock.instant().isBefore(current.expiresAt())) {
+ if ((current.state() == State.ACTIVE || current.state() == State.PENDING)
+ && !clock.instant().isBefore(current.expiresAt())) {
Record expired = copy(current, State.EXPIRED);
store.replaceBreakGlass(current, expired);
audit.record("BREAK_GLASS_EXPIRY", current.principalId(), Optional.empty(), Map.of("state", "EXPIRED"));
@@ -180,4 +212,14 @@ public final class BreakGlassService {
source.issuerPrincipalId(), source.approvalId(), source.createdAt(), source.activatedAt(),
source.expiresAt(), state, source.auditCommitment());
}
+
+ private void requireExecutingApproval(Optional approvalId, String operationId,
+ Permission.Scope scope) {
+ ApprovalService.Request approval = store.requireApproval(approvalId
+ .orElseThrow(() -> new IllegalStateException("Break-glass approval is required")));
+ if (approval.state() != ApprovalService.State.EXECUTING || !approval.operationId().equals(operationId)
+ || !approval.scope().equals(scope)) {
+ throw new IllegalStateException("Break-glass approval is not executable");
+ }
+ }
}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java
index e281291..4b46d82 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java
@@ -51,6 +51,18 @@ import zeroecho.pki.api.PkiId;
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
public final class DisclosureService {
+ /** Durable one-time delivery classification without retaining the raw token. */
+ public enum DeliveryState {
+ PENDING(1), DELIVERED(2), DELIVERY_UNKNOWN(3);
+ private final int code;
+ DeliveryState(int code) { this.code = code; }
+ /** @return stable persistence code */ public int code() { return code; }
+ /** Resolves one stable persistence code. */
+ public static DeliveryState fromCode(int code) {
+ return switch (code) { case 1 -> PENDING; case 2 -> DELIVERED; case 3 -> DELIVERY_UNKNOWN;
+ default -> throw new IllegalArgumentException("Unknown capability delivery code"); };
+ }
+ }
/** Persisted disclosure states, independent of issuance and publication. */
public enum Policy {
PUBLIC(1), PUBLIC_UNLISTED(2), AUTHENTICATED(3), OWNER_ONLY(4), RESTRICTED(5), NOT_PUBLISHED(6);
@@ -133,32 +145,58 @@ public final class DisclosureService {
/** Persisted commitment-only capability authority. */
public record Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
- byte[] tokenCommitment, Instant expiresAt, boolean revoked) {
+ String issuerPrincipalId, byte[] tokenCommitment, Instant expiresAt,
+ DeliveryState deliveryState, boolean revoked) {
/** Validates and snapshots capability metadata. */
public Capability {
Permission.requireId(capabilityId, "capability");
Objects.requireNonNull(realmId, "realmId");
Objects.requireNonNull(objectId, "objectId");
Permission.requireBounded(action, 128, "capability action");
+ Permission.requirePrincipal(issuerPrincipalId);
tokenCommitment = Objects.requireNonNull(tokenCommitment, "tokenCommitment").clone();
if (tokenCommitment.length != 32) throw new IllegalArgumentException("Capability commitment length");
Objects.requireNonNull(expiresAt, "expiresAt");
+ Objects.requireNonNull(deliveryState, "deliveryState");
}
/** Returns a defensive commitment copy. */
@Override public byte[] tokenCommitment() { return tokenCommitment.clone(); }
+
+ /** Creates legacy test metadata with an explicit non-secret system issuer. */
+ public Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
+ byte[] tokenCommitment, Instant expiresAt, boolean revoked) {
+ this(capabilityId, realmId, objectId, action, "system", tokenCommitment, expiresAt, revoked);
+ }
+
+ /** Creates capability metadata in the delivered state for embedded compatibility. */
+ public Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
+ String issuerPrincipalId, byte[] tokenCommitment, Instant expiresAt, boolean revoked) {
+ this(capabilityId, realmId, objectId, action, issuerPrincipalId, tokenCommitment, expiresAt,
+ DeliveryState.DELIVERED, revoked);
+ }
}
/** One-time raw-token issuance result. */
- public record IssuedCapability(Capability capability, byte[] token) {
+ public static final class IssuedCapability {
+ private final Capability capability;
+ private final byte[] token;
+ private boolean consumed;
/** Validates and snapshots the one-time token. */
- public IssuedCapability {
- Objects.requireNonNull(capability, "capability");
- token = Objects.requireNonNull(token, "token").clone();
+ public IssuedCapability(Capability capability, byte[] token) {
+ this.capability = Objects.requireNonNull(capability, "capability");
+ this.token = Objects.requireNonNull(token, "token").clone();
if (token.length != 32) throw new IllegalArgumentException("Capability token length");
}
- /** Returns a defensive one-time token copy. */
- @Override public byte[] token() { return token.clone(); }
+ /** @return persisted commitment-only metadata */ public Capability capability() { return capability; }
+ /** Returns and clears the raw token exactly once. */
+ public synchronized byte[] token() {
+ if (consumed) throw new IllegalStateException("Capability token was already consumed");
+ consumed = true;
+ byte[] result = token.clone();
+ java.util.Arrays.fill(token, (byte) 0);
+ return result;
+ }
}
/** Safe retrieval result that reveals no protected-object existence. */
@@ -244,6 +282,7 @@ public final class DisclosureService {
/** Issues 256 random capability bits and persists only their commitment. */
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
String actorPrincipalId) {
+ store.requirePrincipal(actorPrincipalId);
Record record = store.requireDisclosure(objectId);
if (record.policy() != Policy.PUBLIC_UNLISTED || !expiresAt.isAfter(clock.instant())) {
throw new IllegalStateException("Capability issuance is unavailable for this object");
@@ -253,8 +292,8 @@ public final class DisclosureService {
random.nextBytes(token);
random.nextBytes(identity);
String capabilityId = "cap-" + HexFormat.of().formatHex(identity);
- Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE",
- capabilityCommitment(objectId, expiresAt, token), expiresAt, false);
+ Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE", actorPrincipalId,
+ capabilityCommitment(objectId, expiresAt, token), expiresAt, DeliveryState.PENDING, false);
store.createCapability(capability);
audit.record("CAPABILITY_ISSUE", actorPrincipalId, Optional.of(objectId), Map.of("issued", "true"));
return new IssuedCapability(capability, token);
@@ -264,12 +303,26 @@ public final class DisclosureService {
public synchronized Capability revokeCapability(String capabilityId, String actorPrincipalId) {
Capability current = store.requireCapability(capabilityId);
Capability revoked = new Capability(current.capabilityId(), current.realmId(), current.objectId(),
- current.action(), current.tokenCommitment(), current.expiresAt(), true);
+ current.action(), current.issuerPrincipalId(), current.tokenCommitment(), current.expiresAt(),
+ current.deliveryState(), true);
store.replaceCapability(current, revoked);
audit.record("CAPABILITY_REVOKE", actorPrincipalId, Optional.of(current.objectId()), Map.of("revoked", "true"));
return revoked;
}
+ /** Persists the safe post-write delivery classification without changing token validity. */
+ public synchronized Capability classifyDelivery(String capabilityId, boolean delivered) {
+ Capability current = store.requireCapability(capabilityId);
+ if (current.deliveryState() != DeliveryState.PENDING) return current;
+ Capability updated = new Capability(current.capabilityId(), current.realmId(), current.objectId(),
+ current.action(), current.issuerPrincipalId(), current.tokenCommitment(), current.expiresAt(),
+ delivered ? DeliveryState.DELIVERED : DeliveryState.DELIVERY_UNKNOWN, current.revoked());
+ store.replaceCapability(current, updated);
+ audit.record(delivered ? "CAPABILITY_DELIVERED" : "CAPABILITY_DELIVERY_UNKNOWN",
+ current.issuerPrincipalId(), Optional.of(current.objectId()), Map.of("delivered", Boolean.toString(delivered)));
+ return updated;
+ }
+
private boolean validCapability(PkiId objectId, byte[] token) {
if (token == null || token.length != 32) return false;
for (Capability capability : store.capabilitiesFor(objectId)) {
diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java
index eeafc2f..87001b4 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java
@@ -41,8 +41,11 @@ import java.util.Objects;
import zeroecho.pki.application.PkiOperation;
/** Immutable security descriptors keyed by existing typed-operation identities. */
-@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
+@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
+ "PMD.AvoidDuplicateLiterals" })
public final class OperationSecurityDescriptors {
+ /** Internally fixed execution family; callers cannot select it. */
+ public enum Family { PKI_OPERATION, SERVER_CONTROL_OPERATION }
/** Approval treatment for one operation. */
public enum ApprovalCategory { NONE, HIGH_RISK }
@@ -60,18 +63,27 @@ public final class OperationSecurityDescriptors {
* @param approvalCategory approval treatment
* @param eligibility future transport eligibility metadata
*/
- public record Descriptor(String operationId, Permission.Action action, Permission.ResourceType resourceType,
+ public record Descriptor(String operationId, Family family, Permission.Action action, Permission.ResourceType resourceType,
boolean mutating, Permission.DataView dataView, ApprovalCategory approvalCategory,
Eligibility eligibility) {
/** Validates one finite descriptor. */
public Descriptor {
Permission.requireBounded(operationId, 256, "operation ID");
+ Objects.requireNonNull(family, "family");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(resourceType, "resourceType");
Objects.requireNonNull(dataView, "dataView");
Objects.requireNonNull(approvalCategory, "approvalCategory");
Objects.requireNonNull(eligibility, "eligibility");
}
+
+ /** Creates an existing PKI-family descriptor for embedded callers. */
+ public Descriptor(String operationId, Permission.Action action, Permission.ResourceType resourceType,
+ boolean mutating, Permission.DataView dataView, ApprovalCategory approvalCategory,
+ Eligibility eligibility) {
+ this(operationId, Family.PKI_OPERATION, action, resourceType, mutating, dataView,
+ approvalCategory, eligibility);
+ }
}
private final Map descriptors;
@@ -94,7 +106,41 @@ public final class OperationSecurityDescriptors {
read(PkiOperation.InspectPublication.NAME, Permission.Action.PUBLICATION_READ, Permission.ResourceType.PUBLICATION),
mutate(PkiOperation.ProcessPublication.NAME, Permission.Action.PUBLICATION_PROCESS, Permission.ResourceType.PUBLICATION, false),
read(PkiOperation.ListAlgorithmBindings.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING),
- read(PkiOperation.InspectAlgorithmBinding.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING)));
+ read(PkiOperation.InspectAlgorithmBinding.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING),
+ control(ServerControlOperation.RegisterPrincipal.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, false),
+ control(ServerControlOperation.InspectPrincipal.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, false, false),
+ control(ServerControlOperation.ListPrincipals.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, false, false),
+ control(ServerControlOperation.SetPrincipalEnabled.ENABLE, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, false),
+ control(ServerControlOperation.SetPrincipalEnabled.DISABLE, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, true),
+ control(ServerControlOperation.ListRoleTemplates.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false),
+ control(ServerControlOperation.InspectRoleTemplate.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false),
+ control(ServerControlOperation.CreateRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, true, true),
+ control(ServerControlOperation.InspectRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false),
+ control(ServerControlOperation.ListRoleAssignments.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false),
+ control(ServerControlOperation.RevokeRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, true, false),
+ control(ServerControlOperation.CreateGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, true, true),
+ control(ServerControlOperation.InspectGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false),
+ control(ServerControlOperation.ListGrants.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false),
+ control(ServerControlOperation.RevokeGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, true, false),
+ control(ServerControlOperation.EvaluateAuthorization.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false),
+ control(ServerControlOperation.RequestApproval.NAME, Permission.Action.REQUEST_SUBMIT, Permission.ResourceType.APPROVAL, true, false),
+ control(ServerControlOperation.InspectApproval.NAME, Permission.Action.REQUEST_READ_ANY, Permission.ResourceType.APPROVAL, false, false),
+ control(ServerControlOperation.ListApprovals.NAME, Permission.Action.REQUEST_READ_ANY, Permission.ResourceType.APPROVAL, false, false),
+ control(ServerControlOperation.DecideApproval.APPROVE, Permission.Action.REQUEST_APPROVE, Permission.ResourceType.APPROVAL, true, false),
+ control(ServerControlOperation.DecideApproval.REJECT, Permission.Action.REQUEST_REJECT, Permission.ResourceType.APPROVAL, true, false),
+ control(ServerControlOperation.CancelApproval.NAME, Permission.Action.REQUEST_CANCEL, Permission.ResourceType.APPROVAL, true, false),
+ control(ServerControlOperation.CreateBreakGlass.CREATE, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, true),
+ control(ServerControlOperation.ActivateBreakGlass.ACTIVATE, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, true),
+ control(ServerControlOperation.InspectBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, false, false),
+ control(ServerControlOperation.ListBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, false, false),
+ control(ServerControlOperation.RevokeBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, false),
+ control(ServerControlOperation.InspectDisclosure.NAME, Permission.Action.DISCLOSURE_READ, Permission.ResourceType.DISCLOSURE, false, false),
+ control(ServerControlOperation.SetDisclosure.NAME, Permission.Action.DISCLOSURE_CHANGE, Permission.ResourceType.DISCLOSURE, true, true),
+ control(ServerControlOperation.IssueCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_ISSUE, Permission.ResourceType.CAPABILITY, true, false),
+ control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false),
+ control(ServerControlOperation.InspectAuditView.REDACTED, Permission.Action.AUDIT_READ_REDACTED, Permission.ResourceType.AUDIT, false, false),
+ control(ServerControlOperation.InspectAuditView.FULL, Permission.Action.AUDIT_READ_FULL, Permission.ResourceType.AUDIT, false, false),
+ control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false)));
}
/** Creates a registry and rejects duplicate operation identities. */
@@ -117,11 +163,30 @@ public final class OperationSecurityDescriptors {
return descriptor;
}
+ /** Resolves either operation family through the sole descriptor authority. */
+ public Descriptor require(AdministrativeOperation operation) {
+ Objects.requireNonNull(operation, "operation");
+ Descriptor descriptor = descriptors.get(operation.name());
+ if (descriptor == null) throw new SecurityException("Operation is not exposed by the server gateway");
+ Family expected = operation instanceof AdministrativeOperation.Pki ? Family.PKI_OPERATION
+ : Family.SERVER_CONTROL_OPERATION;
+ if (descriptor.family() != expected) throw new SecurityException("Operation family differs");
+ return descriptor;
+ }
+
/** @return immutable descriptor map keyed by operation ID */
public Map descriptors() {
return descriptors;
}
+ /** Resolves one descriptor by stable ID for strict transport decoding. */
+ public Descriptor require(String operationId) {
+ Permission.requireBounded(operationId, 256, "operation ID");
+ Descriptor descriptor = descriptors.get(operationId);
+ if (descriptor == null) throw new SecurityException("Operation is not exposed by the server gateway");
+ return descriptor;
+ }
+
/** Computes an exact deterministic safe operation commitment. */
public String commitment(RealmId realmId, PkiOperation operation, Permission.Resource resource) {
Descriptor descriptor = require(operation);
@@ -129,6 +194,15 @@ public final class OperationSecurityDescriptors {
+ canonicalArguments(operation) + '|' + canonicalResource(resource));
}
+ /** Computes an exact deterministic commitment for either closed operation family. */
+ public String commitment(RealmId realmId, AdministrativeOperation operation, Permission.Resource resource) {
+ require(operation);
+ String arguments = operation instanceof AdministrativeOperation.Pki pki
+ ? canonicalArguments(pki.operation()) : canonicalControl(((AdministrativeOperation.Control) operation).operation());
+ return ApprovalService.digest(realmId.value() + '|' + operation.name() + '|' + arguments + '|'
+ + canonicalResource(resource));
+ }
+
/** Verifies that caller-supplied security scope matches operation identities. */
public void validateResource(PkiOperation operation, Permission.Resource resource) {
Descriptor descriptor = require(operation);
@@ -214,19 +288,78 @@ public final class OperationSecurityDescriptors {
}
private static Descriptor read(String id, Permission.Action action, Permission.ResourceType type) {
- return new Descriptor(id, action, type, false, Permission.DataView.METADATA_REDACTED,
+ return new Descriptor(id, Family.PKI_OPERATION, action, type, false, Permission.DataView.METADATA_REDACTED,
ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
}
private static Descriptor mutate(String id, Permission.Action action, Permission.ResourceType type,
boolean highRisk) {
- return new Descriptor(id, action, type, true, Permission.DataView.METADATA_REDACTED,
+ return new Descriptor(id, Family.PKI_OPERATION, action, type, true, Permission.DataView.METADATA_REDACTED,
highRisk ? ApprovalCategory.HIGH_RISK : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
}
private static void validateOperationType(PkiOperation operation, Descriptor descriptor) {
- if (!operation.name().equals(descriptor.operationId())) {
+ if (descriptor.family() != Family.PKI_OPERATION || !operation.name().equals(descriptor.operationId())) {
throw new SecurityException("Operation identity and descriptor differ");
}
}
+
+ private static Descriptor control(String id, Permission.Action action, Permission.ResourceType type,
+ boolean mutating, boolean highRisk) {
+ Permission.DataView view = action == Permission.Action.AUDIT_READ_PII
+ ? Permission.DataView.PII_FULL : action == Permission.Action.AUDIT_READ_FULL
+ ? Permission.DataView.METADATA_FULL : Permission.DataView.METADATA_REDACTED;
+ return new Descriptor(id, Family.SERVER_CONTROL_OPERATION, action, type, mutating,
+ view, highRisk ? ApprovalCategory.HIGH_RISK
+ : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
+ }
+
+ private static String canonicalControl(ServerControlOperation operation) {
+ return switch (operation) {
+ case ServerControlOperation.RegisterPrincipal value -> "principal=" + atom(value.principal().principalId());
+ case ServerControlOperation.InspectPrincipal value -> "principal=" + atom(value.principalId());
+ case ServerControlOperation.ListPrincipals value -> "offset=" + value.offset() + ";limit=" + value.limit();
+ case ServerControlOperation.SetPrincipalEnabled value -> "principal=" + atom(value.principalId())
+ + ";enabled=" + value.enabled();
+ case ServerControlOperation.ListRoleTemplates ignored -> "all";
+ case ServerControlOperation.InspectRoleTemplate value -> "template=" + atom(value.templateId())
+ + ";version=" + value.version();
+ case ServerControlOperation.CreateRoleAssignment value -> "assignment="
+ + atom(value.assignment().assignmentId()) + ";principal=" + atom(value.assignment().principalId());
+ case ServerControlOperation.InspectRoleAssignment value -> "assignment=" + atom(value.assignmentId());
+ case ServerControlOperation.ListRoleAssignments value -> "offset=" + value.offset() + ";limit=" + value.limit();
+ case ServerControlOperation.RevokeRoleAssignment value -> "assignment=" + atom(value.assignmentId());
+ case ServerControlOperation.CreateGrant value -> "grant=" + atom(value.grant().grantId())
+ + ";principal=" + atom(value.grant().principalId()) + ";action=" + value.grant().action().name();
+ case ServerControlOperation.InspectGrant value -> "grant=" + atom(value.grantId());
+ case ServerControlOperation.ListGrants value -> "offset=" + value.offset() + ";limit=" + value.limit();
+ case ServerControlOperation.RevokeGrant value -> "grant=" + atom(value.grantId());
+ case ServerControlOperation.EvaluateAuthorization value -> "principal=" + atom(value.principalId())
+ + ";action=" + value.action().name();
+ case ServerControlOperation.RequestApproval value -> "approval=" + atom(value.approvalId())
+ + ";target=" + atom(value.targetOperation().name());
+ case ServerControlOperation.InspectApproval value -> "approval=" + atom(value.approvalId());
+ case ServerControlOperation.ListApprovals value -> "offset=" + value.offset() + ";limit=" + value.limit()
+ + ";state=" + atom(value.state().map(Enum::name).orElse("")) + ";requester="
+ + atom(value.requesterPrincipalId().orElse("")) + ";target="
+ + atom(value.targetOperationId().orElse(""));
+ case ServerControlOperation.DecideApproval value -> "approval=" + atom(value.approvalId())
+ + ";choice=" + value.choice().name();
+ case ServerControlOperation.CancelApproval value -> "approval=" + atom(value.approvalId());
+ case ServerControlOperation.CreateBreakGlass value -> "breakGlass=" + atom(value.breakGlassId())
+ + ";principal=" + atom(value.principalId()) + ";expiry=" + value.expiresAt();
+ case ServerControlOperation.ActivateBreakGlass value -> "breakGlass=" + atom(value.breakGlassId());
+ case ServerControlOperation.InspectBreakGlass value -> "breakGlass=" + atom(value.breakGlassId());
+ case ServerControlOperation.ListBreakGlass value -> "offset=" + value.offset() + ";limit=" + value.limit();
+ case ServerControlOperation.RevokeBreakGlass value -> "breakGlass=" + atom(value.breakGlassId());
+ case ServerControlOperation.InspectDisclosure value -> "object=" + atom(value.objectId().value());
+ case ServerControlOperation.SetDisclosure value -> "object=" + atom(value.objectId().value())
+ + ";policy=" + value.policy().name();
+ case ServerControlOperation.IssueCapability value -> "object=" + atom(value.objectId().value())
+ + ";expiry=" + value.expiresAt();
+ case ServerControlOperation.RevokeCapability value -> "capability=" + atom(value.capabilityId());
+ case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value())
+ + ";view=" + value.view().name();
+ };
+ }
}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java
index 0b18f0a..04a3d28 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java
@@ -67,7 +67,9 @@ public final class Permission {
OCSP_ADMINISTER(87), PUBLICATION_REGISTER(100), PUBLICATION_READ(101),
PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104),
AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123),
- AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142);
+ AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142),
+ DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162),
+ DISCLOSURE_CAPABILITY_REVOKE(163);
private final int code;
@@ -109,7 +111,7 @@ public final class Permission {
REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10),
ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21),
REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32),
- DISCLOSURE(33), CAPABILITY(34);
+ DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36);
private final int code;
ResourceType(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
diff --git a/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java
index 63b8a15..875af1e 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java
@@ -70,7 +70,8 @@ public final class RoleTemplateCatalog {
/** Persistable scoped role assignment; the template itself grants nothing. */
public record Assignment(String assignmentId, String principalId, String templateId,
- int templateVersion, Permission.Scope scope, boolean enabled) {
+ int templateVersion, Permission.Scope scope, java.util.Optional expiresAt,
+ boolean enabled) {
/** Validates the exact scoped assignment. */
public Assignment {
Permission.requireId(assignmentId, "assignment");
@@ -78,6 +79,13 @@ public final class RoleTemplateCatalog {
Permission.requireId(templateId, "role template");
if (templateVersion <= 0) throw new IllegalArgumentException("Template version must be positive");
Objects.requireNonNull(scope, "scope");
+ expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
+ }
+
+ /** Creates an assignment without an expiry for existing embedded callers. */
+ public Assignment(String assignmentId, String principalId, String templateId,
+ int templateVersion, Permission.Scope scope, boolean enabled) {
+ this(assignmentId, principalId, templateId, templateVersion, scope, java.util.Optional.empty(), enabled);
}
}
@@ -169,7 +177,7 @@ public final class RoleTemplateCatalog {
return template.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
.map(action -> new Permission.Grant(exact.assignmentId() + ":" + action.code(), exact.principalId(),
Permission.Effect.ALLOW, action, resourceType(action), exact.scope(),
- relationship(template, action), dataView(action), Set.of(), java.util.Optional.empty(), true))
+ relationship(template, action), dataView(action), Set.of(), exact.expiresAt(), true))
.toList();
}
@@ -188,6 +196,8 @@ public final class RoleTemplateCatalog {
if (name.startsWith("AUDIT")) return Permission.ResourceType.AUDIT;
if (name.startsWith("BACKUP")) return Permission.ResourceType.BACKUP;
if (name.startsWith("RESTORE")) return Permission.ResourceType.RESTORE;
+ if (name.startsWith("DISCLOSURE_CAPABILITY")) return Permission.ResourceType.CAPABILITY;
+ if (name.startsWith("DISCLOSURE")) return Permission.ResourceType.DISCLOSURE;
if (name.startsWith("PRINCIPAL")) return Permission.ResourceType.PRINCIPAL;
if (name.startsWith("ROLE")) return Permission.ResourceType.ROLE;
if (name.startsWith("PERMISSION")) return Permission.ResourceType.GRANT;
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java
new file mode 100644
index 0000000..115430d
--- /dev/null
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java
@@ -0,0 +1,280 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.pki.api.PkiId;
+
+/** Closed transport-neutral server-control administration operation hierarchy. */
+@SuppressWarnings("PMD.ControlStatementBraces")
+public sealed interface ServerControlOperation permits ServerControlOperation.RegisterPrincipal,
+ ServerControlOperation.InspectPrincipal, ServerControlOperation.ListPrincipals,
+ ServerControlOperation.SetPrincipalEnabled, ServerControlOperation.ListRoleTemplates,
+ ServerControlOperation.InspectRoleTemplate, ServerControlOperation.CreateRoleAssignment,
+ ServerControlOperation.InspectRoleAssignment, ServerControlOperation.ListRoleAssignments,
+ ServerControlOperation.RevokeRoleAssignment, ServerControlOperation.CreateGrant,
+ ServerControlOperation.InspectGrant, ServerControlOperation.ListGrants,
+ ServerControlOperation.RevokeGrant, ServerControlOperation.EvaluateAuthorization,
+ ServerControlOperation.RequestApproval, ServerControlOperation.InspectApproval,
+ ServerControlOperation.ListApprovals, ServerControlOperation.DecideApproval,
+ ServerControlOperation.CancelApproval, ServerControlOperation.CreateBreakGlass,
+ ServerControlOperation.ActivateBreakGlass,
+ ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass,
+ ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure,
+ ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability,
+ ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView {
+
+ /** @return stable operation identity */
+ String name();
+
+ /** Principal registration. */
+ record RegisterPrincipal(SecurityPrincipal principal) implements ServerControlOperation {
+ public static final String NAME = "security.principal.register";
+ public RegisterPrincipal { Objects.requireNonNull(principal, "principal"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Principal lookup. */
+ record InspectPrincipal(String principalId) implements ServerControlOperation {
+ public static final String NAME = "security.principal.inspect";
+ public InspectPrincipal { Permission.requirePrincipal(principalId); }
+ @Override public String name() { return NAME; }
+ }
+ /** Bounded principal page. */
+ record ListPrincipals(int offset, int limit) implements ServerControlOperation {
+ public static final String NAME = "security.principal.list";
+ public ListPrincipals { page(offset, limit); }
+ @Override public String name() { return NAME; }
+ }
+ /** Principal enablement transition. */
+ record SetPrincipalEnabled(String principalId, boolean enabled) implements ServerControlOperation {
+ public static final String ENABLE = "security.principal.enable";
+ public static final String DISABLE = "security.principal.disable";
+ public SetPrincipalEnabled { Permission.requirePrincipal(principalId); }
+ @Override public String name() { return enabled ? ENABLE : DISABLE; }
+ }
+ /** Immutable role-template list. */
+ record ListRoleTemplates() implements ServerControlOperation {
+ public static final String NAME = "security.role-template.list";
+ @Override public String name() { return NAME; }
+ }
+ /** Immutable role-template lookup. */
+ record InspectRoleTemplate(String templateId, int version) implements ServerControlOperation {
+ public static final String NAME = "security.role-template.inspect";
+ public InspectRoleTemplate { Permission.requireId(templateId, "role template"); if (version <= 0) throw invalid(); }
+ @Override public String name() { return NAME; }
+ }
+ /** Scoped assignment creation. */
+ record CreateRoleAssignment(RoleTemplateCatalog.Assignment assignment) implements ServerControlOperation {
+ public static final String NAME = "security.role-assignment.create";
+ public CreateRoleAssignment { Objects.requireNonNull(assignment, "assignment"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Scoped assignment lookup. */
+ record InspectRoleAssignment(String assignmentId) implements ServerControlOperation {
+ public static final String NAME = "security.role-assignment.inspect";
+ public InspectRoleAssignment { Permission.requireId(assignmentId, "assignment"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Bounded assignment page. */
+ record ListRoleAssignments(int offset, int limit) implements ServerControlOperation {
+ public static final String NAME = "security.role-assignment.list";
+ public ListRoleAssignments { page(offset, limit); }
+ @Override public String name() { return NAME; }
+ }
+ /** Durable assignment revocation. */
+ record RevokeRoleAssignment(String assignmentId) implements ServerControlOperation {
+ public static final String NAME = "security.role-assignment.revoke";
+ public RevokeRoleAssignment { Permission.requireId(assignmentId, "assignment"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Direct grant creation. */
+ record CreateGrant(Permission.Grant grant) implements ServerControlOperation {
+ public static final String NAME = "security.grant.create";
+ public CreateGrant { Objects.requireNonNull(grant, "grant"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Direct grant lookup. */
+ record InspectGrant(String grantId) implements ServerControlOperation {
+ public static final String NAME = "security.grant.inspect";
+ public InspectGrant { Permission.requireId(grantId, "grant"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Bounded direct-grant page. */
+ record ListGrants(int offset, int limit) implements ServerControlOperation {
+ public static final String NAME = "security.grant.list";
+ public ListGrants { page(offset, limit); }
+ @Override public String name() { return NAME; }
+ }
+ /** Durable direct-grant revocation. */
+ record RevokeGrant(String grantId) implements ServerControlOperation {
+ public static final String NAME = "security.grant.revoke";
+ public RevokeGrant { Permission.requireId(grantId, "grant"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Side-effect-free authorization simulation. */
+ record EvaluateAuthorization(String principalId, Permission.Action action, Permission.Resource resource,
+ Permission.Relationship relationship, Permission.DataView dataView,
+ Permission.Context context) implements ServerControlOperation {
+ public static final String NAME = "security.authorization.evaluate";
+ public EvaluateAuthorization { Permission.requirePrincipal(principalId); Objects.requireNonNull(action);
+ Objects.requireNonNull(resource); Objects.requireNonNull(relationship); Objects.requireNonNull(dataView);
+ Objects.requireNonNull(context); }
+ @Override public String name() { return NAME; }
+ }
+ /** Exact future-operation approval request. */
+ record RequestApproval(String approvalId, AdministrativeOperation targetOperation,
+ Permission.Resource targetResource) implements ServerControlOperation {
+ public static final String NAME = "security.approval.request";
+ public RequestApproval { Permission.requireId(approvalId, "approval");
+ Objects.requireNonNull(targetOperation); Objects.requireNonNull(targetResource);
+ if (targetOperation instanceof AdministrativeOperation.Control control
+ && control.operation() instanceof RequestApproval) throw invalid(); }
+ @Override public String name() { return NAME; }
+ }
+ /** Approval lookup. */
+ record InspectApproval(String approvalId) implements ServerControlOperation {
+ public static final String NAME = "security.approval.inspect";
+ public InspectApproval { Permission.requireId(approvalId, "approval"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Bounded approval page. */
+ record ListApprovals(int offset, int limit, Optional state,
+ Optional requesterPrincipalId, Optional targetOperationId)
+ implements ServerControlOperation {
+ public static final String NAME = "security.approval.list";
+ public ListApprovals { page(offset, limit); state = Objects.requireNonNull(state);
+ requesterPrincipalId = Objects.requireNonNull(requesterPrincipalId)
+ .map(Permission::requirePrincipal);
+ targetOperationId = Objects.requireNonNull(targetOperationId)
+ .map(value -> Permission.requireBounded(value, 256, "target operation")); }
+ /** Creates an unfiltered bounded approval page. */
+ public ListApprovals(int offset, int limit) {
+ this(offset, limit, Optional.empty(), Optional.empty(), Optional.empty());
+ }
+ @Override public String name() { return NAME; }
+ }
+ /** Approval or rejection decision. */
+ record DecideApproval(String approvalId, ApprovalService.Choice choice,
+ String justification) implements ServerControlOperation {
+ public static final String APPROVE = "security.approval.approve";
+ public static final String REJECT = "security.approval.reject";
+ public DecideApproval { Permission.requireId(approvalId, "approval"); Objects.requireNonNull(choice);
+ Permission.requireBounded(justification, 2048, "justification"); }
+ @Override public String name() { return choice == ApprovalService.Choice.APPROVE ? APPROVE : REJECT; }
+ }
+ /** Approval cancellation. */
+ record CancelApproval(String approvalId) implements ServerControlOperation {
+ public static final String NAME = "security.approval.cancel";
+ public CancelApproval { Permission.requireId(approvalId, "approval"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Independently approved emergency grant creation. */
+ record CreateBreakGlass(String breakGlassId, String principalId, Permission.Grant grant,
+ String reason, Instant expiresAt) implements ServerControlOperation {
+ public static final String CREATE = "security.break-glass.create";
+ public CreateBreakGlass { Permission.requireId(breakGlassId, "break-glass");
+ Permission.requirePrincipal(principalId); Objects.requireNonNull(grant);
+ Permission.requireBounded(reason, 2048, "reason"); Objects.requireNonNull(expiresAt); }
+ @Override public String name() { return CREATE; }
+ }
+ /** Activation of one already-created approved emergency grant. */
+ record ActivateBreakGlass(String breakGlassId) implements ServerControlOperation {
+ public static final String ACTIVATE = "security.break-glass.activate";
+ public ActivateBreakGlass { Permission.requireId(breakGlassId, "break-glass"); }
+ @Override public String name() { return ACTIVATE; }
+ }
+ /** Break-glass lookup. */
+ record InspectBreakGlass(String breakGlassId) implements ServerControlOperation {
+ public static final String NAME = "security.break-glass.inspect";
+ public InspectBreakGlass { Permission.requireId(breakGlassId, "break-glass"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Bounded break-glass page. */
+ record ListBreakGlass(int offset, int limit) implements ServerControlOperation {
+ public static final String NAME = "security.break-glass.list";
+ public ListBreakGlass { page(offset, limit); }
+ @Override public String name() { return NAME; }
+ }
+ /** Break-glass revocation. */
+ record RevokeBreakGlass(String breakGlassId) implements ServerControlOperation {
+ public static final String NAME = "security.break-glass.revoke";
+ public RevokeBreakGlass { Permission.requireId(breakGlassId, "break-glass"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Disclosure metadata lookup. */
+ record InspectDisclosure(PkiId objectId) implements ServerControlOperation {
+ public static final String NAME = "security.disclosure.inspect";
+ public InspectDisclosure { Objects.requireNonNull(objectId); }
+ @Override public String name() { return NAME; }
+ }
+ /** Disclosure-only state transition. */
+ record SetDisclosure(PkiId objectId, DisclosureService.Policy policy) implements ServerControlOperation {
+ public static final String NAME = "security.disclosure.set";
+ public SetDisclosure { Objects.requireNonNull(objectId); Objects.requireNonNull(policy); }
+ @Override public String name() { return NAME; }
+ }
+ /** One-time unlisted retrieval capability issuance. */
+ record IssueCapability(PkiId objectId, Instant expiresAt) implements ServerControlOperation {
+ public static final String NAME = "security.disclosure.capability.issue";
+ public IssueCapability { Objects.requireNonNull(objectId); Objects.requireNonNull(expiresAt); }
+ @Override public String name() { return NAME; }
+ }
+ /** Capability revocation by non-bearer record identity. */
+ record RevokeCapability(String capabilityId) implements ServerControlOperation {
+ public static final String NAME = "security.disclosure.capability.revoke";
+ public RevokeCapability { Permission.requireId(capabilityId, "capability"); }
+ @Override public String name() { return NAME; }
+ }
+ /** Existing safe auditor projection over one server-control object. */
+ record InspectAuditView(PkiId objectId, Permission.DataView view,
+ Optional reasonReference) implements ServerControlOperation {
+ public static final String REDACTED = "security.audit-view.inspect-redacted";
+ public static final String FULL = "security.audit-view.inspect-full";
+ public static final String PII = "security.audit-view.inspect-pii";
+ public InspectAuditView { Objects.requireNonNull(objectId); Objects.requireNonNull(view);
+ reasonReference = Objects.requireNonNull(reasonReference); }
+ @Override public String name() { return switch (view) { case METADATA_REDACTED -> REDACTED;
+ case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; }
+ }
+
+ private static void page(int offset, int limit) {
+ if (offset < 0 || limit <= 0 || limit > 256) throw invalid();
+ }
+ private static IllegalArgumentException invalid() {
+ return new IllegalArgumentException("Server-control operation input is invalid");
+ }
+}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java
new file mode 100644
index 0000000..bf38eec
--- /dev/null
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java
@@ -0,0 +1,370 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.application.PkiOperationFailure;
+import zeroecho.pki.application.PkiOperationOutcome;
+import zeroecho.pki.application.PkiOperationResult;
+import zeroecho.pki.application.PkiOperationValue;
+
+/** Sole closed dispatcher for transport-neutral server-control operations. */
+@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveParameterList",
+ "PMD.ControlStatementBraces" })
+public final class ServerControlOperationExecutor {
+ private final RealmId realmId;
+ private final AuthorityExposurePolicy exposure;
+ private final ServerControlStore store;
+ private final RoleTemplateCatalog roles;
+ private final AuthorizationEngine authorization;
+ private final ApprovalService approvals;
+ private final BreakGlassService breakGlass;
+ private final DisclosureService disclosure;
+ private final AuditorViews auditorViews;
+ private final OperationSecurityDescriptors descriptors;
+ private final Map approvalPolicies;
+
+ /** Creates the one control dispatcher over existing durable authorities. */
+ public ServerControlOperationExecutor(RealmId realmId, AuthorityExposurePolicy exposure,
+ ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization,
+ ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
+ AuditorViews auditorViews,
+ OperationSecurityDescriptors descriptors,
+ Map approvalPolicies) {
+ this.realmId = Objects.requireNonNull(realmId, "realmId");
+ this.exposure = Objects.requireNonNull(exposure, "exposure");
+ this.store = Objects.requireNonNull(store, "store");
+ this.roles = Objects.requireNonNull(roles, "roles");
+ this.authorization = Objects.requireNonNull(authorization, "authorization");
+ this.approvals = Objects.requireNonNull(approvals, "approvals");
+ this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
+ this.disclosure = Objects.requireNonNull(disclosure, "disclosure");
+ this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews");
+ this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
+ this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
+ }
+
+ /** Executes exactly one already-authorized control operation synchronously. */
+ public ServerControlOperationOutcome execute(ServerControlOperation operation, String actorPrincipalId,
+ Permission.Resource authorizedResource, Optional approvalId, CancellationSignal cancellation) {
+ Objects.requireNonNull(operation, "operation");
+ Permission.requirePrincipal(actorPrincipalId);
+ Objects.requireNonNull(authorizedResource, "authorizedResource");
+ if (Objects.requireNonNull(cancellation, "cancellation").isCancelled()) {
+ return failure(operation, PkiOperationFailure.CANCELLED, "CONTROL_OPERATION_CANCELLED");
+ }
+ try {
+ return dispatch(operation, actorPrincipalId, authorizedResource,
+ Objects.requireNonNull(approvalId, "approvalId"));
+ } catch (IllegalArgumentException unavailable) {
+ return failure(operation, PkiOperationFailure.NOT_FOUND, "CONTROL_OBJECT_UNAVAILABLE");
+ } catch (SecurityException denied) {
+ return failure(operation, PkiOperationFailure.POLICY_REJECTION, "CONTROL_POLICY_REJECTED");
+ } catch (IllegalStateException conflict) {
+ return failure(operation, PkiOperationFailure.CONFLICT, "CONTROL_STATE_CONFLICT");
+ }
+ }
+
+ private ServerControlOperationOutcome dispatch(ServerControlOperation operation, String actor,
+ Permission.Resource authorizedResource, Optional approvalId) {
+ return switch (operation) {
+ case ServerControlOperation.RegisterPrincipal value -> ordinary(operation, register(value));
+ case ServerControlOperation.InspectPrincipal value -> ordinary(operation,
+ principal(store.requirePrincipal(value.principalId())));
+ case ServerControlOperation.ListPrincipals value -> ordinary(operation,
+ page(store.principals(value.offset(), value.limit()), ServerControlOperationExecutor::principal));
+ case ServerControlOperation.SetPrincipalEnabled value -> ordinary(operation,
+ principal(changePrincipalEnabled(value)));
+ case ServerControlOperation.ListRoleTemplates ignored -> ordinary(operation, templatesValue());
+ case ServerControlOperation.InspectRoleTemplate value -> ordinary(operation,
+ template(roles.require(value.templateId(), value.version())));
+ case ServerControlOperation.CreateRoleAssignment value -> ordinary(operation,
+ assignment(createAssignment(value.assignment())));
+ case ServerControlOperation.InspectRoleAssignment value -> ordinary(operation,
+ assignment(store.requireAssignment(value.assignmentId())));
+ case ServerControlOperation.ListRoleAssignments value -> ordinary(operation,
+ scopedPage(store.assignments(value.offset(), value.limit()), authorizedResource.scope(),
+ RoleTemplateCatalog.Assignment::scope, ServerControlOperationExecutor::assignment));
+ case ServerControlOperation.RevokeRoleAssignment value -> ordinary(operation,
+ assignment(revokeAssignment(value.assignmentId())));
+ case ServerControlOperation.CreateGrant value -> ordinary(operation,
+ grant(createGrant(value.grant())));
+ case ServerControlOperation.InspectGrant value -> ordinary(operation,
+ grant(store.requireGrant(value.grantId())));
+ case ServerControlOperation.ListGrants value -> ordinary(operation,
+ scopedPage(store.grants(value.offset(), value.limit()), authorizedResource.scope(),
+ Permission.Grant::scope, ServerControlOperationExecutor::grant));
+ case ServerControlOperation.RevokeGrant value -> ordinary(operation, grant(revokeGrant(value.grantId())));
+ case ServerControlOperation.EvaluateAuthorization value -> ordinary(operation, evaluate(value));
+ case ServerControlOperation.RequestApproval value -> ordinary(operation, approval(request(value, actor)));
+ case ServerControlOperation.InspectApproval value -> ordinary(operation,
+ approval(approvals.requireCurrent(value.approvalId())));
+ case ServerControlOperation.ListApprovals value -> ordinary(operation,
+ approvalPage(store.approvals(value.offset(), value.limit()), authorizedResource.scope(), value));
+ case ServerControlOperation.DecideApproval value -> ordinary(operation,
+ approval(approvals.decide(value.approvalId(), actor, value.choice(), value.justification())));
+ case ServerControlOperation.CancelApproval value -> ordinary(operation,
+ approval(approvals.cancel(value.approvalId(), actor)));
+ case ServerControlOperation.CreateBreakGlass value -> ordinary(operation,
+ breakGlass(breakGlass.create(value.breakGlassId(), value.principalId(), value.grant(),
+ value.reason(), actor, approvalId, value.expiresAt())));
+ case ServerControlOperation.ActivateBreakGlass value -> ordinary(operation,
+ breakGlass(breakGlass.activatePending(value.breakGlassId(), actor, approvalId)));
+ case ServerControlOperation.InspectBreakGlass value -> ordinary(operation,
+ breakGlass(breakGlass.requireCurrent(value.breakGlassId())));
+ case ServerControlOperation.ListBreakGlass value -> ordinary(operation,
+ scopedPage(store.breakGlass(value.offset(), value.limit()), authorizedResource.scope(),
+ item -> item.grant().scope(), ServerControlOperationExecutor::breakGlass));
+ case ServerControlOperation.RevokeBreakGlass value -> ordinary(operation,
+ breakGlass(breakGlass.revoke(value.breakGlassId(), actor)));
+ case ServerControlOperation.InspectDisclosure value -> ordinary(operation,
+ disclosure(store.requireDisclosure(value.objectId())));
+ case ServerControlOperation.SetDisclosure value -> ordinary(operation,
+ disclosure(disclosure.change(value.objectId(), value.policy(), true, approvalId.isPresent(), actor)));
+ case ServerControlOperation.IssueCapability value -> capability(operation,
+ disclosure.issueCapability(value.objectId(), value.expiresAt(), actor));
+ case ServerControlOperation.RevokeCapability value -> ordinary(operation,
+ capability(disclosure.revokeCapability(value.capabilityId(), actor)));
+ case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, actor));
+ };
+ }
+
+ private PkiOperationValue register(ServerControlOperation.RegisterPrincipal operation) {
+ try { store.createPrincipal(operation.principal()); }
+ catch (IllegalStateException existing) {
+ if (!store.requirePrincipal(operation.principal().principalId()).equals(operation.principal())) throw existing;
+ }
+ return principal(operation.principal());
+ }
+
+ private SecurityPrincipal changePrincipalEnabled(ServerControlOperation.SetPrincipalEnabled operation) {
+ SecurityPrincipal current = store.requirePrincipal(operation.principalId());
+ SecurityPrincipal updated = new SecurityPrincipal(current.principalId(), current.type(),
+ current.displayName(), current.organization(), current.attributes(), operation.enabled());
+ store.replacePrincipal(current, updated);
+ return updated;
+ }
+
+ private RoleTemplateCatalog.Assignment createAssignment(RoleTemplateCatalog.Assignment value) {
+ if (!value.scope().realmId().equals(realmId)) throw new SecurityException("Assignment realm differs");
+ roles.instantiate(value); store.requirePrincipal(value.principalId()); store.createAssignment(value); return value;
+ }
+
+ private RoleTemplateCatalog.Assignment revokeAssignment(String id) {
+ RoleTemplateCatalog.Assignment current = store.requireAssignment(id);
+ RoleTemplateCatalog.Assignment revoked = new RoleTemplateCatalog.Assignment(current.assignmentId(),
+ current.principalId(), current.templateId(), current.templateVersion(), current.scope(),
+ current.expiresAt(), false);
+ store.replaceAssignment(current, revoked); return revoked;
+ }
+
+ private Permission.Grant createGrant(Permission.Grant value) {
+ if (!value.scope().realmId().equals(realmId)) throw new SecurityException("Grant realm differs");
+ store.requirePrincipal(value.principalId()); store.createGrant(value); return value;
+ }
+
+ private Permission.Grant revokeGrant(String id) {
+ Permission.Grant current = store.requireGrant(id);
+ Permission.Grant revoked = new Permission.Grant(current.grantId(), current.principalId(), current.effect(),
+ current.action(), current.resourceType(), current.scope(), current.relationship(), current.dataView(),
+ current.conditions(), current.expiresAt(), false);
+ store.replaceGrant(current, revoked); return revoked;
+ }
+
+ private PkiOperationValue evaluate(ServerControlOperation.EvaluateAuthorization value) {
+ SecurityPrincipal principal = store.requirePrincipal(value.principalId());
+ List grants = grants(principal.principalId());
+ BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId());
+ grants.addAll(emergency.grants());
+ AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId,
+ exposure, principal, value.action(), value.resource(), value.relationship(), value.dataView(),
+ value.context(), grants, emergency.grantIds()));
+ return object("allowed", bool(decision.allowed()), "code", text(decision.code().name()));
+ }
+
+ private ApprovalService.Request request(ServerControlOperation.RequestApproval value, String actor) {
+ OperationSecurityDescriptors.Descriptor descriptor = descriptors.require(value.targetOperation());
+ if (!descriptor.mutating() || descriptor.approvalCategory() == OperationSecurityDescriptors.ApprovalCategory.NONE) {
+ throw new IllegalStateException("Target operation does not require approval");
+ }
+ ApprovalService.Policy policy = approvalPolicies.get(descriptor.approvalCategory());
+ if (policy == null) throw new IllegalStateException("Approval policy is unavailable");
+ String commitment = descriptors.commitment(realmId, value.targetOperation(), value.targetResource());
+ return approvals.request(value.approvalId(), value.targetOperation().name(), commitment,
+ value.targetResource().scope(), actor, policy);
+ }
+
+ private List grants(String principalId) {
+ List result = new ArrayList<>(store.grantsFor(principalId));
+ for (RoleTemplateCatalog.Assignment assignment : store.assignmentsFor(principalId)) {
+ result.addAll(roles.instantiate(assignment));
+ }
+ return result;
+ }
+
+ private PkiOperationValue auditView(ServerControlOperation.InspectAuditView value, String actor) {
+ DisclosureService.Record record = store.requireDisclosure(value.objectId());
+ AuditorViews.Source source = new AuditorViews.Source(record.objectId(), Permission.ResourceType.DISCLOSURE,
+ new Permission.Scope(realmId, Optional.empty(), Optional.empty(), Optional.empty()),
+ record.policy().name(), record.updatedAt(), "NOT_APPLICABLE", Optional.empty(),
+ record.policyCommitment(), false, 0, Optional.empty(), List.of(), Optional.empty());
+ Map fields = new LinkedHashMap<>();
+ fields.put("objectId", text(record.objectId().value())); fields.put("objectType", text(record.objectType().name()));
+ fields.put("policy", text(record.policy().name())); fields.put("updatedAt", text(record.updatedAt().toString()));
+ if (value.view() == Permission.DataView.PII_FULL) {
+ String reason = value.reasonReference().orElseThrow(() -> new SecurityException("PII reason is required"));
+ auditorViews.pii(source, store.requirePrincipal(actor), reason, true, true);
+ } else if (value.view() == Permission.DataView.METADATA_FULL) {
+ auditorViews.fullMetadata(source, true);
+ } else {
+ auditorViews.redacted(source);
+ }
+ fields.put("view", text(value.view().name())); return new PkiOperationValue.ObjectValue(fields);
+ }
+
+ private static ServerControlOperationOutcome ordinary(ServerControlOperation operation,
+ PkiOperationValue value) {
+ return new ServerControlOperationOutcome.Ordinary(new PkiOperationOutcome.Success(
+ new PkiOperationResult(operation.name(), Map.of("value", value))));
+ }
+
+ private static ServerControlOperationOutcome capability(ServerControlOperation operation,
+ DisclosureService.IssuedCapability issued) {
+ PkiOperationResult safe = new PkiOperationResult(operation.name(), Map.of(
+ "capabilityId", text(issued.capability().capabilityId()),
+ "objectId", text(issued.capability().objectId().value()),
+ "expiresAt", text(issued.capability().expiresAt().toString())));
+ byte[] token = issued.token();
+ try {
+ return new ServerControlOperationOutcome.OneTimeSensitive(safe, token);
+ } finally {
+ java.util.Arrays.fill(token, (byte) 0);
+ }
+ }
+
+ private static ServerControlOperationOutcome failure(ServerControlOperation operation,
+ PkiOperationFailure failure, String code) {
+ return new ServerControlOperationOutcome.Ordinary(new PkiOperationOutcome.Failure(
+ operation.name(), failure, code));
+ }
+
+ private static PkiOperationValue principal(SecurityPrincipal value) {
+ return object("principalId", text(value.principalId()), "type", text(value.type().name()),
+ "displayName", text(value.displayName()), "enabled", bool(value.enabled()));
+ }
+ private static PkiOperationValue template(RoleTemplateCatalog.Template value) {
+ List actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
+ .map(item -> (PkiOperationValue) text(item.name())).toList();
+ return object("templateId", text(value.templateId()), "version", integer(value.version()),
+ "actions", new PkiOperationValue.ListValue(actions));
+ }
+ private PkiOperationValue templatesValue() {
+ return new PkiOperationValue.ListValue(roles.templates().stream()
+ .map(ServerControlOperationExecutor::template).toList());
+ }
+ private static PkiOperationValue assignment(RoleTemplateCatalog.Assignment value) {
+ return object("assignmentId", text(value.assignmentId()), "principalId", text(value.principalId()),
+ "templateId", text(value.templateId()), "enabled", bool(value.enabled()));
+ }
+ private static PkiOperationValue grant(Permission.Grant value) {
+ return object("grantId", text(value.grantId()), "principalId", text(value.principalId()),
+ "effect", text(value.effect().name()), "action", text(value.action().name()),
+ "enabled", bool(value.enabled()));
+ }
+ private static PkiOperationValue approval(ApprovalService.Request value) {
+ return object("approvalId", text(value.approvalId()), "operation", text(value.operationId()),
+ "requesterId", text(value.requesterPrincipalId()), "state", text(value.state().name()),
+ "expiresAt", text(value.expiresAt().toString()));
+ }
+ private static PkiOperationValue breakGlass(BreakGlassService.Record value) {
+ return object("breakGlassId", text(value.breakGlassId()), "principalId", text(value.principalId()),
+ "state", text(value.state().name()), "expiresAt", text(value.expiresAt().toString()));
+ }
+ private static PkiOperationValue disclosure(DisclosureService.Record value) {
+ return object("objectId", text(value.objectId().value()), "objectType", text(value.objectType().name()),
+ "policy", text(value.policy().name()), "policyCommitment", text(value.policyCommitment()));
+ }
+ private static PkiOperationValue capability(DisclosureService.Capability value) {
+ return object("capabilityId", text(value.capabilityId()), "objectId", text(value.objectId().value()),
+ "expiresAt", text(value.expiresAt().toString()), "revoked", bool(value.revoked()));
+ }
+ private static PkiOperationValue page(ServerControlStore.Page page,
+ java.util.function.Function renderer) {
+ return object("items", new PkiOperationValue.ListValue(page.values().stream().map(renderer).toList()),
+ "nextOffset", integer(page.nextOffset()), "hasMore", bool(page.hasMore()));
+ }
+ private static PkiOperationValue scopedPage(ServerControlStore.Page page,
+ Permission.Scope requested, java.util.function.Function scope,
+ java.util.function.Function renderer) {
+ List values = page.values().stream()
+ .filter(item -> contains(requested, scope.apply(item))).map(renderer).toList();
+ return object("items", new PkiOperationValue.ListValue(values), "nextOffset",
+ integer(page.nextOffset()), "hasMore", bool(page.hasMore()));
+ }
+ private static PkiOperationValue approvalPage(ServerControlStore.Page page,
+ Permission.Scope requested, ServerControlOperation.ListApprovals filter) {
+ List values = page.values().stream()
+ .filter(item -> contains(requested, item.scope()))
+ .filter(item -> filter.state().map(item.state()::equals).orElse(true))
+ .filter(item -> filter.requesterPrincipalId().map(item.requesterPrincipalId()::equals).orElse(true))
+ .filter(item -> filter.targetOperationId().map(item.operationId()::equals).orElse(true))
+ .map(ServerControlOperationExecutor::approval).toList();
+ return object("items", new PkiOperationValue.ListValue(values), "nextOffset",
+ integer(page.nextOffset()), "hasMore", bool(page.hasMore()));
+ }
+ private static boolean contains(Permission.Scope requested, Permission.Scope actual) {
+ return requested.realmId().equals(actual.realmId())
+ && requested.authorityId().map(value -> actual.authorityId().filter(value::equals).isPresent()).orElse(true)
+ && requested.issuerId().map(value -> actual.issuerId().filter(value::equals).isPresent()).orElse(true)
+ && requested.profileId().map(value -> actual.profileId().filter(value::equals).isPresent()).orElse(true);
+ }
+ private static PkiOperationValue.ObjectValue object(Object... entries) {
+ Map fields = new LinkedHashMap<>();
+ for (int index = 0; index < entries.length; index += 2) {
+ fields.put((String) entries[index], (PkiOperationValue) entries[index + 1]);
+ }
+ return new PkiOperationValue.ObjectValue(fields);
+ }
+ private static PkiOperationValue.Text text(String value) { return new PkiOperationValue.Text(value); }
+ private static PkiOperationValue.BooleanValue bool(boolean value) { return new PkiOperationValue.BooleanValue(value); }
+ private static PkiOperationValue.IntegerValue integer(long value) { return new PkiOperationValue.IntegerValue(value); }
+}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java
new file mode 100644
index 0000000..27ecce5
--- /dev/null
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java
@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import zeroecho.pki.application.PkiOperationOutcome;
+import zeroecho.pki.application.PkiOperationResult;
+
+/** Closed server-control result with explicit one-time-sensitive handling. */
+@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.ControlStatementBraces" })
+public sealed interface ServerControlOperationOutcome permits ServerControlOperationOutcome.Ordinary,
+ ServerControlOperationOutcome.OneTimeSensitive {
+ /** Response handling classification. */
+ enum Classification { ORDINARY, ONE_TIME_SENSITIVE }
+ /** @return response handling classification */
+ Classification classification();
+
+ /** Ordinary safe replayable operation result. */
+ record Ordinary(PkiOperationOutcome outcome) implements ServerControlOperationOutcome {
+ public Ordinary { Objects.requireNonNull(outcome, "outcome"); }
+ @Override public Classification classification() { return Classification.ORDINARY; }
+ }
+
+ /** Single-consumption capability delivery result, never a PKI operation value. */
+ final class OneTimeSensitive implements ServerControlOperationOutcome {
+ private final PkiOperationResult safeResult;
+ private final byte[] token;
+ private final AtomicBoolean consumed = new AtomicBoolean();
+
+ /** Creates a one-time result after its commitment has been durably persisted. */
+ public OneTimeSensitive(PkiOperationResult safeResult, byte[] token) {
+ this.safeResult = Objects.requireNonNull(safeResult, "safeResult");
+ this.token = Objects.requireNonNull(token, "token").clone();
+ if (token.length != 32) throw new IllegalArgumentException("Capability token length is invalid");
+ }
+ @Override public Classification classification() { return Classification.ONE_TIME_SENSITIVE; }
+ /** @return safe non-secret metadata */ public PkiOperationResult safeResult() { return safeResult; }
+ /** Consumes the raw token exactly once and clears the retained copy. */
+ public byte[] consumeToken() {
+ if (!consumed.compareAndSet(false, true)) throw new IllegalStateException("Capability token consumed");
+ byte[] result = token.clone();
+ Arrays.fill(token, (byte) 0);
+ return result;
+ }
+ }
+}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java
index 2d0c722..0565477 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java
@@ -91,7 +91,7 @@ public final class ServerControlStore implements AutoCloseable {
/** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability";
private static final int MAGIC = 0x5a455331;
- private static final int SCHEMA = 1;
+ private static final int SCHEMA = 2;
private static final int MAXIMUM_RECORD_BYTES = 1_048_576;
private static final int MAXIMUM_STRING_BYTES = 16_384;
private static final int MAXIMUM_COLLECTION = 4_096;
@@ -179,11 +179,34 @@ public final class ServerControlStore implements AutoCloseable {
.orElseThrow(() -> new IllegalArgumentException("Principal is unavailable"));
}
+ /** Returns a bounded stable page of principals in metadata-key order. */
+ public synchronized Page principals(int offset, int limit) {
+ return scanPage(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal, offset, limit);
+ }
+
/** Creates one scoped role assignment. */
public synchronized void createAssignment(RoleTemplateCatalog.Assignment value) {
create(ASSIGNMENT, value.assignmentId(), output -> writeAssignment(output, value));
}
+ /** Returns one role assignment. */
+ public synchronized RoleTemplateCatalog.Assignment requireAssignment(String assignmentId) {
+ return read(ASSIGNMENT, assignmentId, KIND_ASSIGNMENT, ServerControlStore::readAssignment)
+ .orElseThrow(() -> new IllegalArgumentException("Role assignment is unavailable"));
+ }
+
+ /** Replaces one exact role assignment. */
+ public synchronized void replaceAssignment(RoleTemplateCatalog.Assignment current,
+ RoleTemplateCatalog.Assignment updated) {
+ requireSame(current.assignmentId(), updated.assignmentId());
+ replace(ASSIGNMENT, current.assignmentId(), output -> writeAssignment(output, updated));
+ }
+
+ /** Returns a bounded stable page of role assignments. */
+ public synchronized Page assignments(int offset, int limit) {
+ return scanPage(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment, offset, limit);
+ }
+
/** Lists assignments for one principal from one stable metadata snapshot. */
public synchronized List assignmentsFor(String principalId) {
return scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment).stream()
@@ -195,6 +218,17 @@ public final class ServerControlStore implements AutoCloseable {
create(GRANT, value.grantId(), output -> writeGrant(output, value));
}
+ /** Returns one direct grant. */
+ public synchronized Permission.Grant requireGrant(String grantId) {
+ return read(GRANT, grantId, KIND_GRANT, ServerControlStore::readGrant)
+ .orElseThrow(() -> new IllegalArgumentException("Permission grant is unavailable"));
+ }
+
+ /** Returns a bounded stable page of direct grants. */
+ public synchronized Page grants(int offset, int limit) {
+ return scanPage(GRANT, KIND_GRANT, ServerControlStore::readGrant, offset, limit);
+ }
+
/** Replaces one direct grant. */
public synchronized void replaceGrant(Permission.Grant current, Permission.Grant updated) {
requireSame(current.grantId(), updated.grantId());
@@ -224,6 +258,11 @@ public final class ServerControlStore implements AutoCloseable {
.orElseThrow(() -> new IllegalArgumentException("Approval is unavailable"));
}
+ /** Returns a bounded stable page of approval records. */
+ public synchronized Page approvals(int offset, int limit) {
+ return scanPage(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval, offset, limit);
+ }
+
/** Creates one break-glass record. */
public synchronized void createBreakGlass(BreakGlassService.Record value) {
create(BREAK_GLASS, value.breakGlassId(), output -> writeBreakGlass(output, value));
@@ -247,6 +286,11 @@ public final class ServerControlStore implements AutoCloseable {
.filter(item -> item.principalId().equals(principalId)).toList();
}
+ /** Returns a bounded stable page of break-glass records. */
+ public synchronized Page breakGlass(int offset, int limit) {
+ return scanPage(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass, offset, limit);
+ }
+
/** Creates one disclosure record. */
public synchronized void createDisclosure(DisclosureService.Record value) {
create(DISCLOSURE, value.objectId().value(), output -> writeDisclosure(output, value));
@@ -264,6 +308,11 @@ public final class ServerControlStore implements AutoCloseable {
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
}
+ /** Returns a bounded stable page of disclosure records. */
+ public synchronized Page disclosures(int offset, int limit) {
+ return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, limit);
+ }
+
/** Creates one commitment-only capability record. */
public synchronized void createCapability(DisclosureService.Capability value) {
create(CAPABILITY, value.capabilityId(), output -> writeCapability(output, value));
@@ -282,6 +331,15 @@ public final class ServerControlStore implements AutoCloseable {
.orElseThrow(() -> new IllegalArgumentException("Capability is unavailable"));
}
+ /** Finite page returned without materializing the complete namespace. */
+ public record Page(List values, int nextOffset, boolean hasMore) {
+ /** Snapshots the bounded page. */
+ public Page {
+ values = List.copyOf(Objects.requireNonNull(values, "values"));
+ if (nextOffset < 0) throw new IllegalArgumentException("Next offset is negative");
+ }
+ }
+
/** Lists capability commitments bound to one object. */
public synchronized List capabilitiesFor(PkiId objectId) {
return scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability).stream()
@@ -330,6 +388,10 @@ public final class ServerControlStore implements AutoCloseable {
: scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure)) {
record.ownerPrincipalId().ifPresent(owner -> requirePrincipalReference(principals, owner));
}
+ for (DisclosureService.Capability capability
+ : scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) {
+ requirePrincipalReference(principals, capability.issuerPrincipalId());
+ }
}
/** Opens no sidecar authority and closes the sole metadata authority idempotently. */
@@ -379,6 +441,34 @@ public final class ServerControlStore implements AutoCloseable {
}
}
+ private Page scanPage(String namespace, int kind, Decoder decoder, int offset, int limit) {
+ requireOpen();
+ if (offset < 0 || limit <= 0 || limit > 256) {
+ throw new IllegalArgumentException("Control page bounds are invalid");
+ }
+ List values = new ArrayList<>(limit);
+ int seen = 0;
+ boolean more = false;
+ try (MetadataSnapshot snapshot = metadata.snapshot();
+ MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(namespace),
+ CancellationSignal.NONE)) {
+ Optional next;
+ while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
+ MetadataSnapshot.Record record = next.orElseThrow();
+ T decoded = decode(record, kind, decoder);
+ if (!record.key().equals(key(namespace, identityOf(decoded)))) {
+ throw new IllegalStateException("Server-control metadata key mismatch");
+ }
+ if (seen++ < offset) continue;
+ if (values.size() == limit) { more = true; break; }
+ values.add(decoded);
+ }
+ return new Page<>(values, Math.addExact(offset, values.size()), more);
+ } catch (IOException failure) {
+ throw new IllegalStateException("Server-control metadata scan failed");
+ }
+ }
+
private void create(String namespace, String identity, Encoder encoder) {
mutate(namespace, identity, true, encoder);
}
@@ -513,12 +603,12 @@ public final class ServerControlStore implements AutoCloseable {
private static void writeAssignment(DataOutputStream out, RoleTemplateCatalog.Assignment value) throws IOException {
out.writeInt(KIND_ASSIGNMENT); writeString(out, value.assignmentId()); writeString(out, value.principalId());
writeString(out, value.templateId()); out.writeInt(value.templateVersion()); writeScope(out, value.scope());
- out.writeBoolean(value.enabled());
+ writeOptionalInstant(out, value.expiresAt()); out.writeBoolean(value.enabled());
}
private static RoleTemplateCatalog.Assignment readAssignment(DataInputStream in) throws IOException {
return new RoleTemplateCatalog.Assignment(readString(in), readString(in), readString(in), in.readInt(),
- readScope(in), in.readBoolean());
+ readScope(in), readOptionalInstant(in), in.readBoolean());
}
private static void writeGrant(DataOutputStream out, Permission.Grant value) throws IOException {
@@ -580,13 +670,13 @@ public final class ServerControlStore implements AutoCloseable {
private static void writeBreakGlass(DataOutputStream out, BreakGlassService.Record value) throws IOException {
out.writeInt(KIND_BREAK_GLASS); writeString(out, value.breakGlassId()); writeString(out, value.principalId());
writeGrantBody(out, value.grant()); writeString(out, value.reason()); writeString(out, value.issuerPrincipalId());
- writeOptionalString(out, value.approvalId()); writeInstant(out, value.createdAt()); writeInstant(out, value.activatedAt());
+ writeOptionalString(out, value.approvalId()); writeInstant(out, value.createdAt()); writeOptionalInstant(out, value.activatedAt());
writeInstant(out, value.expiresAt()); out.writeInt(value.state().code()); writeString(out, value.auditCommitment());
}
private static BreakGlassService.Record readBreakGlass(DataInputStream in) throws IOException {
return new BreakGlassService.Record(readString(in), readString(in), readGrantBody(in), readString(in),
- readString(in), readOptionalString(in), readInstant(in), readInstant(in), readInstant(in),
+ readString(in), readOptionalString(in), readInstant(in), readOptionalInstant(in), readInstant(in),
BreakGlassService.State.fromCode(in.readInt()), readString(in));
}
@@ -603,13 +693,15 @@ public final class ServerControlStore implements AutoCloseable {
private static void writeCapability(DataOutputStream out, DisclosureService.Capability value) throws IOException {
out.writeInt(KIND_CAPABILITY); writeString(out, value.capabilityId()); writeString(out, value.realmId().value());
- writeString(out, value.objectId().value()); writeString(out, value.action()); writeBytes(out, value.tokenCommitment());
- writeInstant(out, value.expiresAt()); out.writeBoolean(value.revoked());
+ writeString(out, value.objectId().value()); writeString(out, value.action());
+ writeString(out, value.issuerPrincipalId()); writeBytes(out, value.tokenCommitment());
+ writeInstant(out, value.expiresAt()); out.writeInt(value.deliveryState().code()); out.writeBoolean(value.revoked());
}
private static DisclosureService.Capability readCapability(DataInputStream in) throws IOException {
return new DisclosureService.Capability(readString(in), new RealmId(readString(in)), new PkiId(readString(in)),
- readString(in), readBytes(in, 32), readInstant(in), in.readBoolean());
+ readString(in), readString(in), readBytes(in, 32), readInstant(in),
+ DisclosureService.DeliveryState.fromCode(in.readInt()), in.readBoolean());
}
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java
index bfa0a68..e5e701b 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java
@@ -58,7 +58,8 @@ import zeroecho.pki.application.PkiResourceScopeResolver;
* exactly once to the session executor, and preserves the returned outcome.
*/
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
- "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" })
+ "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops",
+ "PMD.NcssCount", "PMD.ConfusingTernary", "PMD.ExceptionAsFlowControl" })
public final class ServerOperationGateway {
/**
* Complete transport-neutral request admission input.
@@ -72,7 +73,7 @@ public final class ServerOperationGateway {
* @param approvalId optional durable approval reference
* @param correlationId safe finite request correlation identity
*/
- public record Request(RealmId realmId, String principalId, PkiOperation operation,
+ public record Request(RealmId realmId, String principalId, AdministrativeOperation operation,
Permission.Resource resource, Permission.Relationship relationship, Permission.Context context,
Optional approvalId, String correlationId) {
/** Validates the immutable request. */
@@ -86,14 +87,27 @@ public final class ServerOperationGateway {
approvalId = Objects.requireNonNull(approvalId, "approvalId");
Permission.requireBounded(correlationId, 256, "correlation ID");
}
+
+ /** Wraps an existing PKI operation for source-compatible embedded callers. */
+ public Request(RealmId realmId, String principalId, PkiOperation operation,
+ Permission.Resource resource, Permission.Relationship relationship, Permission.Context context,
+ Optional approvalId, String correlationId) {
+ this(realmId, principalId, new AdministrativeOperation.Pki(operation), resource, relationship,
+ context, approvalId, correlationId);
+ }
}
/** Closed gateway outcome preserving typed backend results. */
- public sealed interface Outcome permits Outcome.Executed, Outcome.Denied, Outcome.ApprovalRequired {
+ public sealed interface Outcome permits Outcome.Executed, Outcome.ControlExecuted,
+ Outcome.Denied, Outcome.ApprovalRequired {
/** Successfully admitted operation and exact backend outcome. */
record Executed(PkiOperationOutcome outcome) implements Outcome {
/** Validates the backend result. */ public Executed { Objects.requireNonNull(outcome, "outcome"); }
}
+ /** Successfully admitted server-control result. */
+ record ControlExecuted(ServerControlOperationOutcome outcome) implements Outcome {
+ /** Validates the control result. */ public ControlExecuted { Objects.requireNonNull(outcome, "outcome"); }
+ }
/** Safe denial without protected-object existence information. */
record Denied(AuthorizationEngine.Code code) implements Outcome {
/** Validates the safe code. */ public Denied { Objects.requireNonNull(code, "code"); }
@@ -117,6 +131,7 @@ public final class ServerOperationGateway {
private final BreakGlassService breakGlass;
private final OperationSecurityDescriptors descriptors;
private final PkiOperationExecutor executor;
+ private final ServerControlOperationExecutor controlExecutor;
private final PkiResourceScopeResolver resourceScopes;
private final Map approvalPolicies;
private final SafeAudit audit;
@@ -125,7 +140,8 @@ public final class ServerOperationGateway {
/** Creates one gateway bound to one realm and one session executor. */
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
- BreakGlassService breakGlass, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
+ BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews,
+ OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
PkiResourceScopeResolver resourceScopes,
Map approvalPolicies,
java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) {
@@ -138,12 +154,27 @@ public final class ServerOperationGateway {
this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
this.executor = Objects.requireNonNull(executor, "executor");
+ this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles,
+ authorization, approvals, breakGlass, disclosure, auditorViews, descriptors, approvalPolicies);
this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes");
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
this.audit = new SafeAudit(clock, auditSink);
this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
}
+ /** Creates the pre-control-plane gateway surface for embedded source compatibility. */
+ public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
+ RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
+ BreakGlassService breakGlass, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
+ PkiResourceScopeResolver resourceScopes,
+ Map approvalPolicies,
+ java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) {
+ this(realmId, exposure, control, roles, authorization, approvals, breakGlass,
+ new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock,
+ new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink),
+ descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, openCheck);
+ }
+
/**
* Admits and synchronously executes one operation.
*
@@ -161,12 +192,21 @@ public final class ServerOperationGateway {
OperationSecurityDescriptors.Descriptor descriptor;
try {
descriptor = descriptors.require(exact.operation());
- descriptors.validateResource(exact.operation(), exact.resource());
- validateAuthoritativeScope(exact.operation(), exact.resource());
- } catch (SecurityException invalid) {
+ if (exact.operation() instanceof AdministrativeOperation.Pki pki) {
+ descriptors.validateResource(pki.operation(), exact.resource());
+ validateAuthoritativeScope(pki.operation(), exact.resource());
+ } else if (descriptor.resourceType() != exact.resource().type()) {
+ throw new SecurityException("Control operation resource type differs");
+ } else {
+ validateControlScope(((AdministrativeOperation.Control) exact.operation()).operation(),
+ exact.resource());
+ }
+ } catch (SecurityException | IllegalArgumentException invalid) {
return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
}
- if (exact.operation() instanceof PkiOperation.CreateAuthority && !exposure.authorityCreationPermitted()) {
+ if (exact.operation() instanceof AdministrativeOperation.Pki pki
+ && pki.operation() instanceof PkiOperation.CreateAuthority
+ && !exposure.authorityCreationPermitted()) {
return denied(exact, AuthorizationEngine.Code.OUTSIDE_AUTHORITY_SCOPE);
}
SecurityPrincipal principal;
@@ -181,16 +221,36 @@ public final class ServerOperationGateway {
}
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId());
grants.addAll(emergency.grants());
- Permission.Action action = action(exact.operation(), descriptor.action());
+ Permission.Action action = exact.operation() instanceof AdministrativeOperation.Pki pki
+ ? action(pki.operation(), descriptor.action()) : descriptor.action();
Permission.Context conditionContext = approvalContext(exact);
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId,
exposure, principal, action, exact.resource(), exact.relationship(), descriptor.dataView(),
conditionContext, grants, emergency.grantIds()));
- boolean filterableList = exact.operation() instanceof PkiOperation.ListAuthorities
+ boolean filterableList = exact.operation() instanceof AdministrativeOperation.Pki pki
+ && pki.operation() instanceof PkiOperation.ListAuthorities
&& decision.code() == AuthorizationEngine.Code.NO_MATCHING_GRANT;
if (!decision.allowed() && !filterableList) {
return denied(exact, decision.code());
}
+ if (exact.operation() instanceof AdministrativeOperation.Control controlOperation
+ && controlOperation.operation() instanceof ServerControlOperation.RequestApproval requestApproval) {
+ OperationSecurityDescriptors.Descriptor target = descriptors.require(requestApproval.targetOperation());
+ if (target.resourceType() != requestApproval.targetResource().type()) {
+ return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
+ }
+ if (requestApproval.targetOperation() instanceof AdministrativeOperation.Pki targetPki) {
+ try {
+ descriptors.validateResource(targetPki.operation(), requestApproval.targetResource());
+ } catch (SecurityException mismatch) {
+ return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
+ }
+ }
+ AuthorizationEngine.Decision targetDecision = authorization.authorize(new AuthorizationEngine.Request(
+ realmId, exposure, principal, target.action(), requestApproval.targetResource(),
+ exact.relationship(), target.dataView(), exact.context(), grants, emergency.grantIds()));
+ if (!targetDecision.allowed()) return denied(exact, targetDecision.code());
+ }
if (decision.usedBreakGlass()) breakGlass.auditUse(principal.principalId());
String commitment = descriptors.commitment(realmId, exact.operation(), exact.resource());
Optional claimedApproval = Optional.empty();
@@ -205,17 +265,25 @@ public final class ServerOperationGateway {
approvals.claim(current.approvalId(), exact.operation().name(), commitment, exact.resource().scope());
claimedApproval = Optional.of(current.approvalId());
}
- PkiOperationOutcome backend = executor.execute(exact.operation(), cancellation);
- if (exact.operation() instanceof PkiOperation.ListAuthorities) {
- backend = filterAuthorities(backend, principal, grants, emergency.grantIds(), exact.context());
- }
- if (claimedApproval.isPresent()) {
- approvals.complete(claimedApproval.orElseThrow(), classification(backend));
+ if (exact.operation() instanceof AdministrativeOperation.Pki pki) {
+ PkiOperationOutcome backend = executor.execute(pki.operation(), cancellation);
+ if (pki.operation() instanceof PkiOperation.ListAuthorities) {
+ backend = filterAuthorities(backend, principal, grants, emergency.grantIds(), exact.context());
+ }
+ if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), classification(backend));
+ audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(),
+ Map.of("operation", exact.operation().name(), "result", classification(backend),
+ "correlation", exact.correlationId()));
+ return new Outcome.Executed(backend);
}
+ ServerControlOperation controlOperation = ((AdministrativeOperation.Control) exact.operation()).operation();
+ ServerControlOperationOutcome controlResult = controlExecutor.execute(controlOperation,
+ principal.principalId(), exact.resource(), exact.approvalId(), cancellation);
+ String result = classification(controlResult);
+ if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), result);
audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(),
- Map.of("operation", exact.operation().name(), "result", classification(backend),
- "correlation", exact.correlationId()));
- return new Outcome.Executed(backend);
+ Map.of("operation", exact.operation().name(), "result", result, "correlation", exact.correlationId()));
+ return new Outcome.ControlExecuted(controlResult);
}
/**
@@ -279,6 +347,18 @@ public final class ServerOperationGateway {
return descriptors.require(operation);
}
+ /** Resolves one descriptor by its immutable administrative operation family. */
+ public OperationSecurityDescriptors.Descriptor descriptor(AdministrativeOperation operation) {
+ openCheck.run();
+ return descriptors.require(operation);
+ }
+
+ /** Resolves one descriptor by stable ID before closed typed decoding. */
+ public OperationSecurityDescriptors.Descriptor descriptor(String operationId) {
+ openCheck.run();
+ return descriptors.require(operationId);
+ }
+
private List grants(SecurityPrincipal principal) {
List grants = new ArrayList<>(control.grantsFor(principal.principalId()));
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
@@ -351,6 +431,28 @@ public final class ServerOperationGateway {
}
}
+ private void validateControlScope(ServerControlOperation operation, Permission.Resource resource) {
+ Permission.Scope actual = switch (operation) {
+ case ServerControlOperation.CreateRoleAssignment value -> value.assignment().scope();
+ case ServerControlOperation.InspectRoleAssignment value -> control.requireAssignment(value.assignmentId()).scope();
+ case ServerControlOperation.RevokeRoleAssignment value -> control.requireAssignment(value.assignmentId()).scope();
+ case ServerControlOperation.CreateGrant value -> value.grant().scope();
+ case ServerControlOperation.InspectGrant value -> control.requireGrant(value.grantId()).scope();
+ case ServerControlOperation.RevokeGrant value -> control.requireGrant(value.grantId()).scope();
+ case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope();
+ case ServerControlOperation.RequestApproval value -> value.targetResource().scope();
+ case ServerControlOperation.InspectApproval value -> approvals.requireCurrent(value.approvalId()).scope();
+ case ServerControlOperation.DecideApproval value -> approvals.requireCurrent(value.approvalId()).scope();
+ case ServerControlOperation.CancelApproval value -> approvals.requireCurrent(value.approvalId()).scope();
+ case ServerControlOperation.CreateBreakGlass value -> value.grant().scope();
+ case ServerControlOperation.ActivateBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
+ case ServerControlOperation.InspectBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
+ case ServerControlOperation.RevokeBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope();
+ default -> resource.scope();
+ };
+ if (!actual.equals(resource.scope())) throw new SecurityException("Control scope differs");
+ }
+
private static boolean requiresResolvedAuthority(PkiOperation operation) {
return operation instanceof PkiOperation.InspectCredential
|| operation instanceof PkiOperation.RevokeCredential
@@ -383,4 +485,10 @@ public final class ServerOperationGateway {
case PkiOperationOutcome.Failure failure -> failure.classification().name();
};
}
+
+ private static String classification(ServerControlOperationOutcome outcome) {
+ if (outcome instanceof ServerControlOperationOutcome.OneTimeSensitive) return "SUCCEEDED";
+ PkiOperationOutcome ordinary = ((ServerControlOperationOutcome.Ordinary) outcome).outcome();
+ return classification(ordinary);
+ }
}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java
index 24fc041..247d885 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java
@@ -96,7 +96,8 @@ public final class ServerRealmContext implements AutoCloseable {
this.auditorViews = auditorViews;
this.audit = new SafeAudit(clock, auditSink);
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
- control, roles, authorization, approvals, breakGlass, new OperationSecurityDescriptors(),
+ control, roles, authorization, approvals, breakGlass, disclosure, auditorViews,
+ new OperationSecurityDescriptors(),
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
this::requireOpen);
}
diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java
index ff6f9a3..ab9dfd3 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java
@@ -335,9 +335,11 @@ final class AdminHttpHandler implements HttpHandler {
private static PkiOperationValue.ObjectValue descriptorValue(OperationSecurityDescriptors.Descriptor value) {
Map fields = new LinkedHashMap<>();
fields.put("operation", new PkiOperationValue.Text(value.operationId()));
+ fields.put("family", new PkiOperationValue.Text(value.family().name()));
fields.put("mutating", new PkiOperationValue.BooleanValue(value.mutating()));
fields.put("resourceType", new PkiOperationValue.Text(value.resourceType().name()));
fields.put("approval", new PkiOperationValue.Text(value.approvalCategory().name()));
+ fields.put("dataView", new PkiOperationValue.Text(value.dataView().name()));
return new PkiOperationValue.ObjectValue(fields);
}
@@ -370,20 +372,49 @@ final class AdminHttpHandler implements HttpHandler {
"MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false));
}
- private static void send(HttpExchange exchange, String requestId, HttpResponses.Response response)
+ private void send(HttpExchange exchange, String requestId, HttpResponses.Response response)
throws IOException {
byte[] body = response.body();
exchange.getResponseHeaders().set("Content-Type", JSON);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
+ if (response.oneTimeSensitive()) {
+ exchange.getResponseHeaders().set("Pragma", "no-cache");
+ exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer");
+ }
exchange.getResponseHeaders().set(RequestIds.HEADER, requestId);
exchange.sendResponseHeaders(response.statusCode(), body.length);
try (java.io.OutputStream output = exchange.getResponseBody()) {
output.write(body);
+ classifyCapabilityDelivery(response, true, requestId);
+ } catch (IOException failure) {
+ if (response.oneTimeSensitive()) {
+ try {
+ classifyCapabilityDelivery(response, false, requestId);
+ } catch (RuntimeException classificationFailure) {
+ failure.addSuppressed(classificationFailure);
+ }
+ realm.auditTransport("CAPABILITY_DELIVERY_UNKNOWN", "system",
+ Map.of("request", requestId, "classification", "CAPABILITY_DELIVERY_UNKNOWN"));
+ }
+ throw failure;
} finally {
+ java.util.Arrays.fill(body, (byte) 0);
+ response.clearSensitiveBody();
exchange.close();
}
}
+ private void classifyCapabilityDelivery(HttpResponses.Response response, boolean delivered,
+ String requestId) {
+ try {
+ response.capabilityId().ifPresent(value -> realm.disclosure().classifyDelivery(value, delivered));
+ } catch (RuntimeException failure) {
+ realm.auditTransport("CAPABILITY_DELIVERY_UNKNOWN", "system",
+ Map.of("request", requestId, "classification", "CAPABILITY_DELIVERY_UNKNOWN"));
+ if (!delivered) throw failure;
+ }
+ }
+
private static final class TransportFailure extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient HttpResponses.Response response;
diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java
index 01e14d7..2a00549 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java
@@ -34,6 +34,7 @@
package zeroecho.pki.server.http;
import java.time.Duration;
+import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
@@ -50,21 +51,27 @@ import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.server.OperationSecurityDescriptors;
+import zeroecho.pki.server.AdministrativeOperation;
+import zeroecho.pki.server.ApprovalService;
+import zeroecho.pki.server.DisclosureService;
import zeroecho.pki.server.Permission;
import zeroecho.pki.server.RealmId;
+import zeroecho.pki.server.RoleTemplateCatalog;
+import zeroecho.pki.server.SecurityPrincipal;
+import zeroecho.pki.server.ServerControlOperation;
/** Closed strict HTTP decoding for operations exposed by the existing gateway. */
@SuppressWarnings("PMD")
final class HttpOperationCodec {
/** Fully decoded gateway input excluding authenticated principal and request ID. */
- record Decoded(PkiOperation operation, Permission.Resource resource, Permission.Context context,
+ record Decoded(AdministrativeOperation operation, Permission.Resource resource, Permission.Context context,
Optional approvalId, Optional requestedDeadline) { }
private HttpOperationCodec() {
}
static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId,
- java.util.function.Function descriptors) {
+ java.util.function.Function descriptors) {
Fields root = Fields.of(StrictJson.parse(document, maximumBytes));
root.allowed(Set.of("version", "authorityId", "approvalId", "deadline", "arguments"));
if (root.integer("version") != 1) throw new IllegalArgumentException("Request version is unsupported");
@@ -73,22 +80,34 @@ final class HttpOperationCodec {
approval.ifPresent(value -> requireId(value, "approval ID"));
Optional deadline = root.optionalText("deadline").map(HttpOperationCodec::duration);
Fields arguments = root.object("arguments");
- PkiOperation operation = operation(operationId, arguments);
+ OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operationId);
+ AdministrativeOperation operation;
+ Permission.Scope scope;
+ Optional objectId;
+ if (descriptor.family() == OperationSecurityDescriptors.Family.PKI_OPERATION) {
+ PkiOperation pki = operation(operationId, arguments);
+ operation = new AdministrativeOperation.Pki(pki);
+ boolean realmWide = realmWide(pki);
+ if (realmWide == authority.isPresent()) {
+ throw new IllegalArgumentException(realmWide
+ ? "Realm-wide operation must not specify authorityId"
+ : "Authority-scoped operation requires authorityId");
+ }
+ validateAuthority(pki, authority);
+ scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(pki));
+ objectId = objectId(pki);
+ } else {
+ ServerControlOperation control = control(operationId, arguments, realmId, authority, descriptors);
+ operation = new AdministrativeOperation.Control(control);
+ scope = controlScope(control, realmId, authority);
+ objectId = controlObject(control);
+ }
arguments.complete();
root.complete();
- OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operation);
- boolean realmWide = realmWide(operation);
- if (realmWide == authority.isPresent()) {
- throw new IllegalArgumentException(realmWide
- ? "Realm-wide operation must not specify authorityId"
- : "Authority-scoped operation requires authorityId");
- }
- validateAuthority(operation, authority);
- Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(operation));
- Optional objectId = objectId(operation);
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId,
Optional.empty());
- Optional reason = operation instanceof PkiOperation.TransitionAuthority transition
+ Optional reason = operation instanceof AdministrativeOperation.Pki pki
+ && pki.operation() instanceof PkiOperation.TransitionAuthority transition
? Optional.of(transition.reason()) : Optional.empty();
Permission.Context context = new Permission.Context(reason, approval.isPresent(), false, Map.of());
return new Decoded(operation, resource, context, approval, deadline);
@@ -167,6 +186,212 @@ final class HttpOperationCodec {
};
}
+ private static ServerControlOperation control(String id, Fields fields, RealmId realmId,
+ Optional authority,
+ java.util.function.Function descriptors) {
+ return switch (id) {
+ case ServerControlOperation.RegisterPrincipal.NAME -> {
+ fields.exact("principalId", "type", "displayName", "enabled");
+ yield new ServerControlOperation.RegisterPrincipal(new SecurityPrincipal(fields.text("principalId"),
+ SecurityPrincipal.Type.valueOf(fields.text("type")), fields.text("displayName"),
+ Optional.empty(), Map.of(), fields.bool("enabled")));
+ }
+ case ServerControlOperation.InspectPrincipal.NAME -> {
+ fields.exact("principalId"); yield new ServerControlOperation.InspectPrincipal(fields.text("principalId"));
+ }
+ case ServerControlOperation.ListPrincipals.NAME -> {
+ fields.exact("offset", "limit"); yield new ServerControlOperation.ListPrincipals(
+ fields.integer("offset"), fields.integer("limit"));
+ }
+ case ServerControlOperation.SetPrincipalEnabled.ENABLE,
+ ServerControlOperation.SetPrincipalEnabled.DISABLE -> {
+ fields.exact("principalId"); yield new ServerControlOperation.SetPrincipalEnabled(
+ fields.text("principalId"), id.equals(ServerControlOperation.SetPrincipalEnabled.ENABLE));
+ }
+ case ServerControlOperation.ListRoleTemplates.NAME -> {
+ fields.exact(); yield new ServerControlOperation.ListRoleTemplates();
+ }
+ case ServerControlOperation.InspectRoleTemplate.NAME -> {
+ fields.exact("templateId", "version"); yield new ServerControlOperation.InspectRoleTemplate(
+ fields.text("templateId"), fields.integer("version"));
+ }
+ case ServerControlOperation.CreateRoleAssignment.NAME -> {
+ fields.allowed(Set.of("assignmentId", "principalId", "templateId", "templateVersion",
+ "profileId", "expiresAt"));
+ Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(),
+ fields.optionalText("profileId"));
+ yield new ServerControlOperation.CreateRoleAssignment(new RoleTemplateCatalog.Assignment(
+ fields.text("assignmentId"), fields.text("principalId"), fields.text("templateId"),
+ fields.integer("templateVersion"), scope, fields.optionalInstant("expiresAt"), true));
+ }
+ case ServerControlOperation.InspectRoleAssignment.NAME -> {
+ fields.exact("assignmentId"); yield new ServerControlOperation.InspectRoleAssignment(
+ fields.text("assignmentId"));
+ }
+ case ServerControlOperation.ListRoleAssignments.NAME -> {
+ fields.exact("offset", "limit"); yield new ServerControlOperation.ListRoleAssignments(
+ fields.integer("offset"), fields.integer("limit"));
+ }
+ case ServerControlOperation.RevokeRoleAssignment.NAME -> {
+ fields.exact("assignmentId"); yield new ServerControlOperation.RevokeRoleAssignment(
+ fields.text("assignmentId"));
+ }
+ case ServerControlOperation.CreateGrant.NAME -> {
+ fields.allowed(Set.of("grantId", "principalId", "effect", "action", "resourceType",
+ "issuerId", "profileId", "relationship", "dataView", "conditions", "expiresAt"));
+ Permission.Scope scope = new Permission.Scope(realmId, authority,
+ fields.optionalText("issuerId").map(PkiId::new), fields.optionalText("profileId"));
+ yield new ServerControlOperation.CreateGrant(new Permission.Grant(fields.text("grantId"),
+ fields.text("principalId"), Permission.Effect.valueOf(fields.text("effect")),
+ Permission.Action.valueOf(fields.text("action")),
+ Permission.ResourceType.valueOf(fields.text("resourceType")), scope,
+ Permission.Relationship.valueOf(fields.text("relationship")),
+ Permission.DataView.valueOf(fields.text("dataView")),
+ fields.enumSet("conditions", Permission.Condition.class), fields.optionalInstant("expiresAt"), true));
+ }
+ case ServerControlOperation.InspectGrant.NAME -> {
+ fields.exact("grantId"); yield new ServerControlOperation.InspectGrant(fields.text("grantId"));
+ }
+ case ServerControlOperation.ListGrants.NAME -> {
+ fields.exact("offset", "limit"); yield new ServerControlOperation.ListGrants(
+ fields.integer("offset"), fields.integer("limit"));
+ }
+ case ServerControlOperation.RevokeGrant.NAME -> {
+ fields.exact("grantId"); yield new ServerControlOperation.RevokeGrant(fields.text("grantId"));
+ }
+ case ServerControlOperation.EvaluateAuthorization.NAME -> {
+ fields.exact("principalId", "action", "resourceType", "relationship", "dataView");
+ Permission.Resource resource = new Permission.Resource(
+ Permission.ResourceType.valueOf(fields.text("resourceType")),
+ new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()),
+ Optional.empty(), Optional.empty());
+ yield new ServerControlOperation.EvaluateAuthorization(fields.text("principalId"),
+ Permission.Action.valueOf(fields.text("action")), resource,
+ Permission.Relationship.valueOf(fields.text("relationship")),
+ Permission.DataView.valueOf(fields.text("dataView")), Permission.Context.empty());
+ }
+ case ServerControlOperation.RequestApproval.NAME -> {
+ fields.allowed(Set.of("approvalId", "targetOperation", "targetArguments", "targetAuthorityId"));
+ String targetId = fields.text("targetOperation");
+ Fields targetFields = fields.object("targetArguments");
+ Optional targetAuthority = fields.optionalText("targetAuthorityId").map(PkiId::new);
+ OperationSecurityDescriptors.Descriptor targetDescriptor = descriptors.apply(targetId);
+ AdministrativeOperation targetOperation;
+ Permission.Scope targetScope;
+ Optional targetObject;
+ if (targetDescriptor.family() == OperationSecurityDescriptors.Family.PKI_OPERATION) {
+ PkiOperation targetPki = operation(targetId, targetFields);
+ targetOperation = new AdministrativeOperation.Pki(targetPki);
+ targetScope = new Permission.Scope(realmId, targetAuthority, Optional.empty(), profile(targetPki));
+ targetObject = objectId(targetPki);
+ } else {
+ ServerControlOperation targetControl = control(targetId, targetFields, realmId,
+ targetAuthority, descriptors);
+ targetOperation = new AdministrativeOperation.Control(targetControl);
+ targetScope = controlScope(targetControl, realmId, targetAuthority);
+ targetObject = controlObject(targetControl);
+ }
+ targetFields.complete();
+ Permission.Resource targetResource = new Permission.Resource(targetDescriptor.resourceType(),
+ targetScope, targetObject, Optional.empty());
+ yield new ServerControlOperation.RequestApproval(fields.text("approvalId"),
+ targetOperation, targetResource);
+ }
+ case ServerControlOperation.InspectApproval.NAME -> {
+ fields.exact("approvalId"); yield new ServerControlOperation.InspectApproval(fields.text("approvalId"));
+ }
+ case ServerControlOperation.ListApprovals.NAME -> {
+ fields.allowed(Set.of("offset", "limit", "state", "requesterPrincipalId", "targetOperationId"));
+ yield new ServerControlOperation.ListApprovals(fields.integer("offset"), fields.integer("limit"),
+ fields.optionalText("state").map(ApprovalService.State::valueOf),
+ fields.optionalText("requesterPrincipalId"), fields.optionalText("targetOperationId"));
+ }
+ case ServerControlOperation.DecideApproval.APPROVE,
+ ServerControlOperation.DecideApproval.REJECT -> {
+ fields.exact("approvalId", "justification"); yield new ServerControlOperation.DecideApproval(
+ fields.text("approvalId"), id.equals(ServerControlOperation.DecideApproval.APPROVE)
+ ? ApprovalService.Choice.APPROVE : ApprovalService.Choice.REJECT,
+ fields.text("justification"));
+ }
+ case ServerControlOperation.CancelApproval.NAME -> {
+ fields.exact("approvalId"); yield new ServerControlOperation.CancelApproval(fields.text("approvalId"));
+ }
+ case ServerControlOperation.CreateBreakGlass.CREATE -> {
+ fields.exact("breakGlassId", "principalId", "grantId", "action", "resourceType", "reason", "expiresAt");
+ Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
+ Permission.Grant grant = new Permission.Grant(fields.text("grantId"), fields.text("principalId"),
+ Permission.Effect.ALLOW, Permission.Action.valueOf(fields.text("action")),
+ Permission.ResourceType.valueOf(fields.text("resourceType")), scope,
+ Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED, Set.of(),
+ Optional.of(fields.instant("expiresAt")), true);
+ yield new ServerControlOperation.CreateBreakGlass(fields.text("breakGlassId"),
+ fields.text("principalId"), grant, fields.text("reason"), fields.instant("expiresAt"));
+ }
+ case ServerControlOperation.ActivateBreakGlass.ACTIVATE -> {
+ fields.exact("breakGlassId");
+ yield new ServerControlOperation.ActivateBreakGlass(fields.text("breakGlassId"));
+ }
+ case ServerControlOperation.InspectBreakGlass.NAME -> {
+ fields.exact("breakGlassId"); yield new ServerControlOperation.InspectBreakGlass(fields.text("breakGlassId"));
+ }
+ case ServerControlOperation.ListBreakGlass.NAME -> {
+ fields.exact("offset", "limit"); yield new ServerControlOperation.ListBreakGlass(
+ fields.integer("offset"), fields.integer("limit"));
+ }
+ case ServerControlOperation.RevokeBreakGlass.NAME -> {
+ fields.exact("breakGlassId"); yield new ServerControlOperation.RevokeBreakGlass(fields.text("breakGlassId"));
+ }
+ case ServerControlOperation.InspectDisclosure.NAME -> {
+ fields.exact("objectId"); yield new ServerControlOperation.InspectDisclosure(fields.pkiId("objectId"));
+ }
+ case ServerControlOperation.SetDisclosure.NAME -> {
+ fields.exact("objectId", "policy"); yield new ServerControlOperation.SetDisclosure(
+ fields.pkiId("objectId"), DisclosureService.Policy.valueOf(fields.text("policy")));
+ }
+ case ServerControlOperation.IssueCapability.NAME -> {
+ fields.exact("objectId", "expiresAt"); yield new ServerControlOperation.IssueCapability(
+ fields.pkiId("objectId"), fields.instant("expiresAt"));
+ }
+ case ServerControlOperation.RevokeCapability.NAME -> {
+ fields.exact("capabilityId"); yield new ServerControlOperation.RevokeCapability(fields.text("capabilityId"));
+ }
+ case ServerControlOperation.InspectAuditView.REDACTED,
+ ServerControlOperation.InspectAuditView.FULL,
+ ServerControlOperation.InspectAuditView.PII -> {
+ fields.allowed(Set.of("objectId", "reasonReference"));
+ Permission.DataView view = id.equals(ServerControlOperation.InspectAuditView.REDACTED)
+ ? Permission.DataView.METADATA_REDACTED
+ : id.equals(ServerControlOperation.InspectAuditView.FULL)
+ ? Permission.DataView.METADATA_FULL : Permission.DataView.PII_FULL;
+ yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view,
+ fields.optionalText("reasonReference"));
+ }
+ default -> throw new SecurityException("Control operation is not exposed");
+ };
+ }
+
+ private static Permission.Scope controlScope(ServerControlOperation operation, RealmId realmId,
+ Optional authority) {
+ return switch (operation) {
+ case ServerControlOperation.CreateRoleAssignment value -> value.assignment().scope();
+ case ServerControlOperation.CreateGrant value -> value.grant().scope();
+ case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope();
+ case ServerControlOperation.RequestApproval value -> value.targetResource().scope();
+ case ServerControlOperation.CreateBreakGlass value -> value.grant().scope();
+ default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty());
+ };
+ }
+
+ private static Optional controlObject(ServerControlOperation operation) {
+ return switch (operation) {
+ case ServerControlOperation.InspectDisclosure value -> Optional.of(value.objectId());
+ case ServerControlOperation.SetDisclosure value -> Optional.of(value.objectId());
+ case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId());
+ case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId());
+ default -> Optional.empty();
+ };
+ }
+
private static boolean realmWide(PkiOperation operation) {
return operation instanceof PkiOperation.ListAuthorities
|| operation instanceof PkiOperation.CreateAuthority
@@ -247,7 +472,24 @@ final class HttpOperationCodec {
long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name);
if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); }
int integer(String name) { return Math.toIntExact(longValue(name)); }
+ boolean bool(String name) { consumed.add(name); PkiOperationValue value = require(name);
+ if (!(value instanceof PkiOperationValue.BooleanValue bool)) throw type(); return bool.value(); }
PkiId pkiId(String name) { return new PkiId(text(name)); }
+ Instant instant(String name) { try { return Instant.parse(text(name)); }
+ catch (java.time.format.DateTimeParseException failure) { throw type(); } }
+ Optional optionalInstant(String name) { return fields.containsKey(name)
+ ? Optional.of(instant(name)) : Optional.empty(); }
+ > Set enumSet(String name, Class type) {
+ consumed.add(name); PkiOperationValue value = require(name);
+ if (!(value instanceof PkiOperationValue.ListValue list)) throw type();
+ Set result = new LinkedHashSet<>();
+ for (PkiOperationValue item : list.values()) {
+ if (!(item instanceof PkiOperationValue.Text text) || !result.add(Enum.valueOf(type, text.value()))) {
+ throw type();
+ }
+ }
+ return Set.copyOf(result);
+ }
Fields object(String name) { consumed.add(name); return of(require(name)); }
void complete() { if (!Objects.equals(consumed, fields.keySet()))
throw new IllegalArgumentException("Request fields were not consumed"); }
diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java
index 16c599e..7875241 100644
--- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java
+++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java
@@ -43,13 +43,18 @@ import zeroecho.pki.application.PkiOperationFailure;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.server.ServerOperationGateway;
+import zeroecho.pki.server.ServerControlOperationOutcome;
/** Deterministic versioned transport response and status mapping. */
@SuppressWarnings("PMD")
final class HttpResponses {
- record Response(int statusCode, byte[] body) {
- Response { body = Objects.requireNonNull(body, "body").clone(); }
+ record Response(int statusCode, byte[] body, boolean oneTimeSensitive,
+ java.util.Optional capabilityId) {
+ Response { body = Objects.requireNonNull(body, "body").clone();
+ capabilityId = Objects.requireNonNull(capabilityId, "capabilityId"); }
+ Response(int statusCode, byte[] body) { this(statusCode, body, false, java.util.Optional.empty()); }
@Override public byte[] body() { return body.clone(); }
+ void clearSensitiveBody() { if (oneTimeSensitive) java.util.Arrays.fill(body, (byte) 0); }
}
private static final JsonFactory JSON = JsonFactory.builder().build();
@@ -61,6 +66,8 @@ final class HttpResponses {
return switch (outcome) {
case ServerOperationGateway.Outcome.Executed executed -> backend(requestId, operation,
executed.outcome());
+ case ServerOperationGateway.Outcome.ControlExecuted executed -> control(requestId, operation,
+ executed.outcome());
case ServerOperationGateway.Outcome.Denied denied -> failure(403, requestId, operation,
"DENIED", "AUTHORIZATION_DENIED", denied.code().name(), false, false);
case ServerOperationGateway.Outcome.ApprovalRequired required -> successLikeFailure(409, requestId,
@@ -68,6 +75,27 @@ final class HttpResponses {
};
}
+ private static Response control(String requestId, String operation, ServerControlOperationOutcome outcome) {
+ if (outcome instanceof ServerControlOperationOutcome.Ordinary ordinary) {
+ return backend(requestId, operation, ordinary.outcome());
+ }
+ ServerControlOperationOutcome.OneTimeSensitive sensitive =
+ (ServerControlOperationOutcome.OneTimeSensitive) outcome;
+ byte[] token = sensitive.consumeToken();
+ try {
+ MapBuilder result = new MapBuilder(sensitive.safeResult().fields());
+ result.put("token", new PkiOperationValue.Text(java.util.Base64.getUrlEncoder()
+ .withoutPadding().encodeToString(token)));
+ Response encoded = encode(200, requestId, operation, "SUCCEEDED",
+ new PkiOperationValue.ObjectValue(result.values()), null);
+ String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields()
+ .get("capabilityId")).value();
+ return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId));
+ } finally {
+ java.util.Arrays.fill(token, (byte) 0);
+ }
+ }
+
static Response failure(int status, String requestId, String operation, String classification,
String code, String messageKey, boolean recoveryRequired, boolean reconciliationRequired) {
return encode(status, requestId, operation, "FAILED", null,
@@ -153,4 +181,11 @@ final class HttpResponses {
private record Failure(String classification, String code, String messageKey,
boolean recoveryRequired, boolean reconciliationRequired) { }
+
+ private static final class MapBuilder {
+ private final java.util.Map values = new java.util.LinkedHashMap<>();
+ private MapBuilder(java.util.Map initial) { values.putAll(initial); }
+ private void put(String key, PkiOperationValue value) { values.put(key, value); }
+ private java.util.Map values() { return values; }
+ }
}
diff --git a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json
index 3c32cda..1ef0396 100644
--- a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json
+++ b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"roles": [
{"id":"platform-operator","version":1,"actions":["REALM_READ","SERVER_HEALTH_READ","SERVER_CONFIGURATION_READ","SERVER_CONFIGURATION_UPDATE"]},
- {"id":"security-administrator","version":1,"actions":["REALM_READ","IDENTITY_PROVIDER_MANAGE","PRINCIPAL_MANAGE","ROLE_MANAGE","PERMISSION_GRANT"]},
+ {"id":"security-administrator","version":1,"actions":["REALM_READ","IDENTITY_PROVIDER_MANAGE","PRINCIPAL_MANAGE","ROLE_MANAGE","PERMISSION_GRANT","DISCLOSURE_READ","DISCLOSURE_CHANGE","DISCLOSURE_CAPABILITY_ISSUE","DISCLOSURE_CAPABILITY_REVOKE"]},
{"id":"ca-security-officer","version":1,"actions":["AUTHORITY_LIST","AUTHORITY_READ","AUTHORITY_CREATE","AUTHORITY_IMPORT","AUTHORITY_ACTIVATE","AUTHORITY_SUSPEND","AUTHORITY_RETIRE","ISSUER_CREATE","ISSUER_ROTATE","ISSUER_RETIRE","CA_CHAIN_DOWNLOAD"]},
{"id":"profile-policy-manager","version":1,"actions":["PROFILE_READ","PROFILE_REGISTER","PROFILE_VALIDATE","PROFILE_ACTIVATE","PROFILE_DEACTIVATE","POLICY_READ","POLICY_UPDATE","X509_BINDING_READ","X509_BINDING_PROVIDER_ENABLE"]},
{"id":"enrollment-officer","version":1,"actions":["REQUEST_SUBMIT","REQUEST_READ_ANY","CERTIFICATE_ISSUE","CERTIFICATE_READ_METADATA","CERTIFICATE_DOWNLOAD"]},
@@ -12,8 +12,8 @@
{"id":"publication-operator","version":1,"actions":["PUBLICATION_REGISTER","PUBLICATION_READ","PUBLICATION_PROCESS","PUBLICATION_RETRY","PUBLICATION_RECONCILE"]},
{"id":"backup-operator","version":1,"actions":["BACKUP_EXPORT","BACKUP_VERIFY"]},
{"id":"recovery-officer","version":1,"actions":["RESTORE_EXECUTE"]},
- {"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ"]},
- {"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII"]},
+ {"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ","DISCLOSURE_READ"]},
+ {"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII","DISCLOSURE_READ"]},
{"id":"acme-administrator","version":1,"actions":["REALM_READ","AUTHORITY_READ","PROFILE_READ","POLICY_READ"]},
{"id":"public-principal","version":1,"actions":["REALM_READ","CA_CHAIN_DOWNLOAD","CRL_DOWNLOAD"]}
]
diff --git a/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java b/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java
index 50acf3c..1fe3ecf 100644
--- a/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java
+++ b/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java
@@ -143,13 +143,33 @@ class ControlApprovalDisclosureTest {
Permission.ResourceType.REVOCATION, ServerTestSupport.scope(), Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED);
BreakGlassService service = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
- BreakGlassService.Record active = service.activate("break-a", "officer", emergency,
- "incident-response", "security-admin", Optional.empty(),
- ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1)));
+ ApprovalService.Policy emergencyPolicy = new ApprovalService.Policy("emergency-policy", 1,
+ Set.of("security-admin"), Set.of(), true, Duration.ofHours(1), true);
+ ApprovalService approvalService = new ApprovalService(opened.store(), ServerTestSupport.CLOCK,
+ opened.audit());
+ ApprovalService.Request creation = approvalService.request("approval-break-create",
+ ServerControlOperation.CreateBreakGlass.CREATE, ServerTestSupport.DIGEST,
+ ServerTestSupport.scope(), "officer", emergencyPolicy);
+ approvalService.decide(creation.approvalId(), "security-admin", ApprovalService.Choice.APPROVE,
+ "independent emergency review");
+ approvalService.claim(creation.approvalId(), ServerControlOperation.CreateBreakGlass.CREATE,
+ ServerTestSupport.DIGEST, ServerTestSupport.scope());
+ service.create("break-a", "officer", emergency, "incident-response", "security-admin",
+ Optional.of(creation.approvalId()), ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1)));
+ ApprovalService.Request activation = approvalService.request("approval-break-activate",
+ ServerControlOperation.ActivateBreakGlass.ACTIVATE, ServerTestSupport.DIGEST,
+ ServerTestSupport.scope(), "officer", emergencyPolicy);
+ approvalService.decide(activation.approvalId(), "security-admin", ApprovalService.Choice.APPROVE,
+ "activation reviewed");
+ approvalService.claim(activation.approvalId(), ServerControlOperation.ActivateBreakGlass.ACTIVATE,
+ ServerTestSupport.DIGEST, ServerTestSupport.scope());
+ BreakGlassService.Record active = service.activatePending("break-a", "security-admin",
+ Optional.of(activation.approvalId()));
assertEquals(BreakGlassService.State.ACTIVE, active.state());
assertEquals(1, service.activeFor("officer").grants().size());
- assertThrows(IllegalArgumentException.class, () -> service.activate("break-b", "officer", emergency,
- "incident", "officer", Optional.empty(), ServerTestSupport.CLOCK.instant().plusSeconds(60)));
+ assertThrows(IllegalStateException.class, () -> service.create("break-b", "officer", emergency,
+ "incident", "security-admin", Optional.empty(),
+ ServerTestSupport.CLOCK.instant().plusSeconds(60)));
assertTrue(opened.audit().events().stream().anyMatch(event -> event.action().contains("ACTIVATE")));
BreakGlassService expired = new BreakGlassService(opened.store(),
Clock.fixed(active.expiresAt(), ZoneOffset.UTC), opened.audit());
@@ -164,6 +184,7 @@ class ControlApprovalDisclosureTest {
System.out.println("disclosureSeparatesIdentityOwnershipPublicationAndCapability");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("owner-a"));
+ opened.store().createPrincipal(ServerTestSupport.principal("officer"));
SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG");
deterministic.setSeed(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
DisclosureService service = new DisclosureService(ServerTestSupport.REALM, opened.store(),
@@ -192,24 +213,26 @@ class ControlApprovalDisclosureTest {
service.change(leaf, DisclosureService.Policy.PUBLIC_UNLISTED, true, true, "officer");
DisclosureService.IssuedCapability issued = service.issueCapability(leaf,
ServerTestSupport.CLOCK.instant().plusSeconds(600), "officer");
- assertNotEquals(java.util.HexFormat.of().formatHex(issued.token()),
+ byte[] issuedToken = issued.token();
+ assertNotEquals(java.util.HexFormat.of().formatHex(issuedToken),
java.util.HexFormat.of().formatHex(issued.capability().tokenCommitment()));
assertEquals(DisclosureService.Decision.ALLOWED,
- service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token())));
+ service.decide(leaf, Optional.empty(), false, false, Optional.of(issuedToken)));
DisclosureService.Capability wrongAction = new DisclosureService.Capability("cap-wrong-action",
ServerTestSupport.REALM, leaf, "INSPECT", issued.capability().tokenCommitment(),
issued.capability().expiresAt(), false);
opened.store().createCapability(wrongAction);
service.revokeCapability(issued.capability().capabilityId(), "officer");
assertEquals(DisclosureService.Decision.DENIED,
- service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token())));
- byte[] wrong = issued.token();
+ service.decide(leaf, Optional.empty(), false, false, Optional.of(issuedToken)));
+ byte[] wrong = issuedToken.clone();
wrong[0] ^= 1;
assertEquals(DisclosureService.Decision.DENIED,
service.decide(leaf, Optional.empty(), false, false, Optional.of(wrong)));
assertArrayEquals(issued.capability().tokenCommitment(),
opened.store().requireCapability(issued.capability().capabilityId()).tokenCommitment());
- System.out.println("...token-bytes=" + issued.token().length + ", persisted=commitment-only");
+ assertThrows(IllegalStateException.class, issued::token);
+ System.out.println("...token-bytes=" + issuedToken.length + ", persisted=commitment-only");
}
System.out.println("...ok");
}
diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java
index 069aef3..4586789 100644
--- a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java
+++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java
@@ -137,6 +137,16 @@ class PkiHttpsServerTest {
"{\"version\":1,\"arguments\":{\"limit\":10}}");
assertEquals(200, executed.statusCode());
assertTrue(executed.body().contains("\"status\":\"SUCCEEDED\""));
+ HttpResponse principalCreated = post(client,
+ base.resolve("/admin/v1/operations/security.principal.register"),
+ "{\"version\":1,\"arguments\":{\"principalId\":\"operator-b\",\"type\":\"USER\","
+ + "\"displayName\":\"Operator B\",\"enabled\":true}}");
+ assertEquals(200, principalCreated.statusCode());
+ HttpResponse principalInspected = post(client,
+ base.resolve("/admin/v1/operations/security.principal.inspect"),
+ "{\"version\":1,\"arguments\":{\"principalId\":\"operator-b\"}}");
+ assertEquals(200, principalInspected.statusCode());
+ assertTrue(principalInspected.body().contains("operator-b"));
HttpResponse hidden = get(client, base.resolve("/admin/v1/operations/credential.issue"));
assertEquals(404, hidden.statusCode());
HttpResponse malformed = post(client,
@@ -198,6 +208,9 @@ class PkiHttpsServerTest {
context.grant(ServerTestSupport.grant("binding-read", "administrator", Permission.Effect.ALLOW,
Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING, realmScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system");
+ context.grant(ServerTestSupport.grant("principal-manage", "administrator", Permission.Effect.ALLOW,
+ Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, realmScope,
+ Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system");
}
}
diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java
new file mode 100644
index 0000000..03a0dc0
--- /dev/null
+++ b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java
@@ -0,0 +1,238 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import zeroecho.core.io.CancellationSignal;
+import zeroecho.pki.api.FormatId;
+import zeroecho.pki.api.KeyRef;
+import zeroecho.pki.api.PkiId;
+import zeroecho.pki.api.SubjectRef;
+import zeroecho.pki.application.PkiOperation;
+
+/** Durable closed control-operation, approval and one-time-result coverage. */
+class ServerControlOperationExecutorTest {
+ @TempDir Path temporaryDirectory;
+
+ @Test
+ void catalogContainsOneExplicitPairOfOperationFamilies() {
+ System.out.println("catalogContainsOneExplicitPairOfOperationFamilies");
+ OperationSecurityDescriptors catalog = new OperationSecurityDescriptors();
+ assertEquals(50, catalog.descriptors().size());
+ assertEquals(16, catalog.descriptors().values().stream()
+ .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
+ assertEquals(34, catalog.descriptors().values().stream()
+ .filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION)
+ .count());
+ assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION,
+ catalog.require(ServerControlOperation.IssueCapability.NAME).family());
+ System.out.println("...control-operations=34");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void principalAssignmentAndGrantOperationsPersistAndPage() throws Exception {
+ System.out.println("principalAssignmentAndGrantOperationsPersistAndPage");
+ try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
+ ServerControlOperationExecutor executor = executor(opened);
+ SecurityPrincipal principal = ServerTestSupport.principal("operator-a");
+ ordinary(executor.execute(new ServerControlOperation.RegisterPrincipal(principal), "operator-a",
+ resource(Permission.ResourceType.PRINCIPAL, realmScope()), Optional.empty(),
+ CancellationSignal.NONE));
+ RoleTemplateCatalog.Assignment assignment = new RoleTemplateCatalog.Assignment("assignment-a",
+ principal.principalId(), "auditor", 1, ServerTestSupport.scope(), Optional.empty(), true);
+ ordinary(executor.execute(new ServerControlOperation.CreateRoleAssignment(assignment), "operator-a",
+ resource(Permission.ResourceType.ROLE, ServerTestSupport.scope()), Optional.empty(),
+ CancellationSignal.NONE));
+ Permission.Grant deny = ServerTestSupport.grant("deny-a", principal.principalId(),
+ Permission.Effect.DENY, Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY,
+ ServerTestSupport.scope(), Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED);
+ ordinary(executor.execute(new ServerControlOperation.CreateGrant(deny), "operator-a",
+ resource(Permission.ResourceType.GRANT, ServerTestSupport.scope()), Optional.empty(),
+ CancellationSignal.NONE));
+ assertEquals(1, opened.store().principals(0, 10).values().size());
+ assertEquals(1, opened.store().assignments(0, 10).values().size());
+ assertEquals(Permission.Effect.DENY, opened.store().requireGrant("deny-a").effect());
+ System.out.println("...persisted=principal,assignment,deny-grant");
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void approvalRequestUsesConfiguredPolicyAndDoesNotExecuteTarget() throws Exception {
+ System.out.println("approvalRequestUsesConfiguredPolicyAndDoesNotExecuteTarget");
+ try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
+ opened.store().createPrincipal(ServerTestSupport.principal("requester-a"));
+ opened.store().createPrincipal(ServerTestSupport.principal("approver-a"));
+ ServerControlOperationExecutor executor = executor(opened);
+ PkiOperation target = new PkiOperation.CreateAuthority(new FormatId("x509"),
+ new SubjectRef("subject-a"), "profile-a", new KeyRef("key-a"));
+ Permission.Resource targetResource = resource(Permission.ResourceType.AUTHORITY, realmScope());
+ ServerControlOperation.RequestApproval request = new ServerControlOperation.RequestApproval(
+ "approval-a", new AdministrativeOperation.Pki(target), targetResource);
+ ordinary(executor.execute(request, "requester-a", resource(Permission.ResourceType.APPROVAL,
+ realmScope()), Optional.empty(), CancellationSignal.NONE));
+ ApprovalService.Request persisted = opened.store().requireApproval("approval-a");
+ assertEquals(ApprovalService.State.PENDING, persisted.state());
+ assertEquals("high-risk", persisted.policy().policyId());
+ System.out.println("...target-state=PENDING,no-execution");
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void capabilityResultIsSingleConsumptionAndPersistenceContainsOnlyCommitment() throws Exception {
+ System.out.println("capabilityResultIsSingleConsumptionAndPersistenceContainsOnlyCommitment");
+ try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
+ opened.store().createPrincipal(ServerTestSupport.principal("security-admin"));
+ PkiId objectId = new PkiId("credential-a");
+ DisclosureService disclosure = disclosure(opened);
+ disclosure.register(objectId, DisclosureService.ObjectType.LEAF_CERTIFICATE,
+ DisclosureService.Policy.PUBLIC_UNLISTED, Optional.of("security-admin"),
+ ServerTestSupport.DIGEST, "security-admin");
+ ServerControlOperationExecutor executor = executor(opened, disclosure);
+ ServerControlOperationOutcome outcome = executor.execute(new ServerControlOperation.IssueCapability(
+ objectId, ServerTestSupport.CLOCK.instant().plusSeconds(600)), "security-admin",
+ resource(Permission.ResourceType.CAPABILITY, realmScope()), Optional.empty(),
+ CancellationSignal.NONE);
+ ServerControlOperationOutcome.OneTimeSensitive sensitive = assertInstanceOf(
+ ServerControlOperationOutcome.OneTimeSensitive.class, outcome);
+ byte[] token = sensitive.consumeToken();
+ assertEquals(32, token.length);
+ assertThrows(IllegalStateException.class, sensitive::consumeToken);
+ String capabilityId = ((zeroecho.pki.application.PkiOperationValue.Text)
+ sensitive.safeResult().fields().get("capabilityId")).value();
+ assertEquals(32, opened.store().requireCapability(capabilityId).tokenCommitment().length);
+ assertEquals(DisclosureService.DeliveryState.PENDING,
+ opened.store().requireCapability(capabilityId).deliveryState());
+ assertEquals(DisclosureService.DeliveryState.DELIVERY_UNKNOWN,
+ disclosure.classifyDelivery(capabilityId, false).deliveryState());
+ System.out.println("...token-bytes=32,persisted=commitment-only");
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void pendingBreakGlassRequiresSeparateActivationAndExpires() throws Exception {
+ System.out.println("pendingBreakGlassRequiresSeparateActivationAndExpires");
+ try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
+ opened.store().createPrincipal(ServerTestSupport.principal("officer-a"));
+ opened.store().createPrincipal(ServerTestSupport.principal("security-admin"));
+ Permission.Grant grant = ServerTestSupport.grant("emergency-a", "officer-a", Permission.Effect.ALLOW,
+ Permission.Action.CERTIFICATE_REVOKE, Permission.ResourceType.REVOCATION,
+ ServerTestSupport.scope(), Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED);
+ BreakGlassService service = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
+ ApprovalService.Policy policy = new ApprovalService.Policy("emergency-policy", 1,
+ Set.of("security-admin"), Set.of(), true, Duration.ofHours(1), true);
+ ApprovalService approvals = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
+ ApprovalService.Request createApproval = approvals.request("approval-create",
+ ServerControlOperation.CreateBreakGlass.CREATE, ServerTestSupport.DIGEST,
+ ServerTestSupport.scope(), "officer-a", policy);
+ approvals.decide(createApproval.approvalId(), "security-admin", ApprovalService.Choice.APPROVE,
+ "creation independently reviewed");
+ approvals.claim(createApproval.approvalId(), ServerControlOperation.CreateBreakGlass.CREATE,
+ ServerTestSupport.DIGEST, ServerTestSupport.scope());
+ BreakGlassService.Record pending = service.create("break-a", "officer-a", grant, "incident-response",
+ "security-admin", Optional.of(createApproval.approvalId()),
+ ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1)));
+ assertEquals(BreakGlassService.State.PENDING, pending.state());
+ assertTrue(service.activeFor("officer-a").grants().isEmpty());
+ ApprovalService.Request activateApproval = approvals.request("approval-activate",
+ ServerControlOperation.ActivateBreakGlass.ACTIVATE, ServerTestSupport.DIGEST,
+ ServerTestSupport.scope(), "officer-a", policy);
+ approvals.decide(activateApproval.approvalId(), "security-admin", ApprovalService.Choice.APPROVE,
+ "activation independently reviewed");
+ approvals.claim(activateApproval.approvalId(), ServerControlOperation.ActivateBreakGlass.ACTIVATE,
+ ServerTestSupport.DIGEST, ServerTestSupport.scope());
+ assertEquals(BreakGlassService.State.ACTIVE, service.activatePending("break-a", "security-admin",
+ Optional.of(activateApproval.approvalId())).state());
+ System.out.println("...states=PENDING,ACTIVE");
+ }
+ System.out.println("...ok");
+ }
+
+ private ServerControlOperationExecutor executor(ServerTestSupport.OpenedStore opened) throws Exception {
+ return executor(opened, disclosure(opened));
+ }
+
+ private ServerControlOperationExecutor executor(ServerTestSupport.OpenedStore opened,
+ DisclosureService disclosure) {
+ ApprovalService.Policy policy = new ApprovalService.Policy("high-risk", 1, Set.of("approver-a"),
+ Set.of(), true, Duration.ofHours(1), true);
+ OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
+ return new ServerControlOperationExecutor(ServerTestSupport.REALM,
+ new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true),
+ opened.store(), RoleTemplateCatalog.load(getClass().getClassLoader()),
+ new AuthorizationEngine(ServerTestSupport.CLOCK),
+ new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()),
+ new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure,
+ new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), descriptors,
+ Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy));
+ }
+
+ private DisclosureService disclosure(ServerTestSupport.OpenedStore opened) throws Exception {
+ SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
+ random.setSeed(new byte[] { 1, 2, 3, 4 });
+ return new DisclosureService(ServerTestSupport.REALM, opened.store(),
+ DisclosureService.Defaults.recommended(), ServerTestSupport.CLOCK, random, opened.audit());
+ }
+
+ private static Permission.Scope realmScope() {
+ return new Permission.Scope(ServerTestSupport.REALM, Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ private static Permission.Resource resource(Permission.ResourceType type, Permission.Scope scope) {
+ return new Permission.Resource(type, scope, Optional.empty(), Optional.empty());
+ }
+
+ private static void ordinary(ServerControlOperationOutcome outcome) {
+ ServerControlOperationOutcome.Ordinary ordinary = assertInstanceOf(
+ ServerControlOperationOutcome.Ordinary.class, outcome);
+ assertInstanceOf(zeroecho.pki.application.PkiOperationOutcome.Success.class, ordinary.outcome());
+ }
+}
diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java
index 5f83893..8205974 100644
--- a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java
+++ b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java
@@ -184,7 +184,9 @@ class ServerOperationGatewayTest {
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
- assertEquals(16, descriptors.descriptors().size());
+ assertEquals(50, descriptors.descriptors().size());
+ assertEquals(16, descriptors.descriptors().values().stream()
+ .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count());
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));
OperationSecurityDescriptors.Descriptor descriptor = descriptors.descriptors()
.get(PkiOperation.InspectAuthority.NAME);
diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java
new file mode 100644
index 0000000..02385ad
--- /dev/null
+++ b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java
@@ -0,0 +1,98 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.server.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import zeroecho.pki.server.AdministrativeOperation;
+import zeroecho.pki.server.OperationSecurityDescriptors;
+import zeroecho.pki.server.RealmId;
+import zeroecho.pki.server.ServerControlOperation;
+
+/** Strict HTTPS decoding coverage for the closed server-control operation family. */
+class HttpServerControlOperationCodecTest {
+ private static final RealmId REALM = new RealmId("production");
+ private static final OperationSecurityDescriptors DESCRIPTORS = new OperationSecurityDescriptors();
+
+ @Test
+ void decodesPrincipalRegistrationIntoClosedControlType() {
+ System.out.println("decodesPrincipalRegistrationIntoClosedControlType");
+ String json = "{\"version\":1,\"arguments\":{\"principalId\":\"operator-a\","
+ + "\"type\":\"USER\",\"displayName\":\"Operator A\",\"enabled\":true}}";
+ HttpOperationCodec.Decoded decoded = decode(ServerControlOperation.RegisterPrincipal.NAME, json);
+ AdministrativeOperation.Control family = assertInstanceOf(AdministrativeOperation.Control.class,
+ decoded.operation());
+ ServerControlOperation.RegisterPrincipal operation = assertInstanceOf(
+ ServerControlOperation.RegisterPrincipal.class, family.operation());
+ assertEquals("operator-a", operation.principal().principalId());
+ System.out.println("...family=SERVER_CONTROL_OPERATION");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsUnknownControlArgumentBeforeGatewayInvocation() {
+ System.out.println("rejectsUnknownControlArgumentBeforeGatewayInvocation");
+ String json = "{\"version\":1,\"arguments\":{\"principalId\":\"operator-a\","
+ + "\"unexpected\":true}}";
+ assertThrows(IllegalArgumentException.class,
+ () -> decode(ServerControlOperation.InspectPrincipal.NAME, json));
+ System.out.println("...unknown-field=rejected");
+ System.out.println("...ok");
+ }
+
+ @Test
+ void capabilityIssuanceDecodesWithoutBinaryOrTokenInput() {
+ System.out.println("capabilityIssuanceDecodesWithoutBinaryOrTokenInput");
+ String json = "{\"version\":1,\"arguments\":{\"objectId\":\"credential-a\","
+ + "\"expiresAt\":\"2026-08-04T12:10:00Z\"}}";
+ HttpOperationCodec.Decoded decoded = decode(ServerControlOperation.IssueCapability.NAME, json);
+ ServerControlOperation.IssueCapability operation = assertInstanceOf(
+ ServerControlOperation.IssueCapability.class,
+ ((AdministrativeOperation.Control) decoded.operation()).operation());
+ assertEquals("credential-a", operation.objectId().value());
+ System.out.println("...request-token-fields=0");
+ System.out.println("...ok");
+ }
+
+ private static HttpOperationCodec.Decoded decode(String operation, String json) {
+ return HttpOperationCodec.decode(operation, json.getBytes(StandardCharsets.UTF_8), 16_384,
+ REALM, DESCRIPTORS::require);
+ }
+}