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.
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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<String> approvalId, Instant createdAt, Instant activatedAt,
|
||||
String issuerPrincipalId, Optional<String> approvalId, Instant createdAt, Optional<Instant> 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<String> 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<String> 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<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<String, Descriptor> 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<String, Descriptor> 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<java.time.Instant> 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;
|
||||
|
||||
@@ -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<ApprovalService.State> state,
|
||||
Optional<String> requesterPrincipalId, Optional<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> 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<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> 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<String> 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<String> 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<Permission.Grant> 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<Permission.Grant> grants(String principalId) {
|
||||
List<Permission.Grant> 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<String, PkiOperationValue> 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<PkiOperationValue> 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 <T> PkiOperationValue page(ServerControlStore.Page<T> page,
|
||||
java.util.function.Function<T, PkiOperationValue> renderer) {
|
||||
return object("items", new PkiOperationValue.ListValue(page.values().stream().map(renderer).toList()),
|
||||
"nextOffset", integer(page.nextOffset()), "hasMore", bool(page.hasMore()));
|
||||
}
|
||||
private static <T> PkiOperationValue scopedPage(ServerControlStore.Page<T> page,
|
||||
Permission.Scope requested, java.util.function.Function<T, Permission.Scope> scope,
|
||||
java.util.function.Function<T, PkiOperationValue> renderer) {
|
||||
List<PkiOperationValue> 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<ApprovalService.Request> page,
|
||||
Permission.Scope requested, ServerControlOperation.ListApprovals filter) {
|
||||
List<PkiOperationValue> 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<String, PkiOperationValue> 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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SecurityPrincipal> 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<RoleTemplateCatalog.Assignment> 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<RoleTemplateCatalog.Assignment> 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<Permission.Grant> 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<ApprovalService.Request> 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<BreakGlassService.Record> 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<DisclosureService.Record> 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<T>(List<T> 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<DisclosureService.Capability> 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 <T> Page<T> scanPage(String namespace, int kind, Decoder<T> decoder, int offset, int limit) {
|
||||
requireOpen();
|
||||
if (offset < 0 || limit <= 0 || limit > 256) {
|
||||
throw new IllegalArgumentException("Control page bounds are invalid");
|
||||
}
|
||||
List<T> 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<MetadataSnapshot.Record> 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 {
|
||||
|
||||
@@ -58,7 +58,8 @@ import zeroecho.pki.application.PkiResourceScopeResolver;
|
||||
* exactly once to the session executor, and preserves the returned outcome.</p>
|
||||
*/
|
||||
@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<String> 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<String> 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<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> 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<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> 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<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> 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<String> 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<Permission.Grant> grants(SecurityPrincipal principal) {
|
||||
List<Permission.Grant> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -335,9 +335,11 @@ final class AdminHttpHandler implements HttpHandler {
|
||||
private static PkiOperationValue.ObjectValue descriptorValue(OperationSecurityDescriptors.Descriptor value) {
|
||||
Map<String, PkiOperationValue> 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;
|
||||
|
||||
@@ -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<String> approvalId, Optional<Duration> requestedDeadline) { }
|
||||
|
||||
private HttpOperationCodec() {
|
||||
}
|
||||
|
||||
static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId,
|
||||
java.util.function.Function<PkiOperation, OperationSecurityDescriptors.Descriptor> descriptors) {
|
||||
java.util.function.Function<String, OperationSecurityDescriptors.Descriptor> 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<Duration> 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<PkiId> 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<PkiId> objectId = objectId(operation);
|
||||
Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId,
|
||||
Optional.empty());
|
||||
Optional<String> reason = operation instanceof PkiOperation.TransitionAuthority transition
|
||||
Optional<String> 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<PkiId> authority,
|
||||
java.util.function.Function<String, OperationSecurityDescriptors.Descriptor> 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<PkiId> targetAuthority = fields.optionalText("targetAuthorityId").map(PkiId::new);
|
||||
OperationSecurityDescriptors.Descriptor targetDescriptor = descriptors.apply(targetId);
|
||||
AdministrativeOperation targetOperation;
|
||||
Permission.Scope targetScope;
|
||||
Optional<PkiId> 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<PkiId> 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<PkiId> 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<Instant> optionalInstant(String name) { return fields.containsKey(name)
|
||||
? Optional.of(instant(name)) : Optional.empty(); }
|
||||
<E extends Enum<E>> Set<E> enumSet(String name, Class<E> type) {
|
||||
consumed.add(name); PkiOperationValue value = require(name);
|
||||
if (!(value instanceof PkiOperationValue.ListValue list)) throw type();
|
||||
Set<E> 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"); }
|
||||
|
||||
@@ -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<String> 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<String, PkiOperationValue> values = new java.util.LinkedHashMap<>();
|
||||
private MapBuilder(java.util.Map<String, PkiOperationValue> initial) { values.putAll(initial); }
|
||||
private void put(String key, PkiOperationValue value) { values.put(key, value); }
|
||||
private java.util.Map<String, PkiOperationValue> values() { return values; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]}
|
||||
]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -137,6 +137,16 @@ class PkiHttpsServerTest {
|
||||
"{\"version\":1,\"arguments\":{\"limit\":10}}");
|
||||
assertEquals(200, executed.statusCode());
|
||||
assertTrue(executed.body().contains("\"status\":\"SUCCEEDED\""));
|
||||
HttpResponse<String> 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<String> 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<String> hidden = get(client, base.resolve("/admin/v1/operations/credential.issue"));
|
||||
assertEquals(404, hidden.statusCode());
|
||||
HttpResponse<String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user