feat(pki-server): add multi-CA security foundation
Add the durable multi-authority realm, scoped default-deny authorization, approval and break-glass workflows, auditor views and disclosure policy. Enforce all administration through the transport-neutral secured operation gateway while preserving immutable PKI authority and future HTTP reuse.
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
/*******************************************************************************
|
||||
* 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.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.api.PkiId;
|
||||
import zeroecho.pki.application.PkiOperation;
|
||||
import zeroecho.pki.application.PkiOperationExecutor;
|
||||
import zeroecho.pki.application.PkiOperationOutcome;
|
||||
import zeroecho.pki.application.PkiOperationResult;
|
||||
import zeroecho.pki.application.PkiOperationValue;
|
||||
import zeroecho.pki.application.PkiResourceScopeResolver;
|
||||
|
||||
/**
|
||||
* In-process transport-neutral security gateway for existing typed PKI
|
||||
* operations.
|
||||
*
|
||||
* <p>The gateway owns no executor, thread, queue, retry, or PKI business rule. It
|
||||
* serially validates realm/scope, authorizes, applies approval policy, delegates
|
||||
* exactly once to the session executor, and preserves the returned outcome.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
|
||||
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" })
|
||||
public final class ServerOperationGateway {
|
||||
/**
|
||||
* Complete transport-neutral request admission input.
|
||||
*
|
||||
* @param realmId exact realm identity
|
||||
* @param principalId authenticated or public principal identity
|
||||
* @param operation existing typed operation
|
||||
* @param resource exact non-bearer resource reference
|
||||
* @param relationship established object relationship
|
||||
* @param context safe closed-condition context
|
||||
* @param approvalId optional durable approval reference
|
||||
* @param correlationId safe finite request correlation identity
|
||||
*/
|
||||
public record Request(RealmId realmId, String principalId, PkiOperation operation,
|
||||
Permission.Resource resource, Permission.Relationship relationship, Permission.Context context,
|
||||
Optional<String> approvalId, String correlationId) {
|
||||
/** Validates the immutable request. */
|
||||
public Request {
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Permission.requirePrincipal(principalId);
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(resource, "resource");
|
||||
Objects.requireNonNull(relationship, "relationship");
|
||||
Objects.requireNonNull(context, "context");
|
||||
approvalId = Objects.requireNonNull(approvalId, "approvalId");
|
||||
Permission.requireBounded(correlationId, 256, "correlation ID");
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed gateway outcome preserving typed backend results. */
|
||||
public sealed interface Outcome permits Outcome.Executed, 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"); }
|
||||
}
|
||||
/** Safe denial without protected-object existence information. */
|
||||
record Denied(AuthorizationEngine.Code code) implements Outcome {
|
||||
/** Validates the safe code. */ public Denied { Objects.requireNonNull(code, "code"); }
|
||||
}
|
||||
/** High-risk operation requires a separately created durable approval. */
|
||||
record ApprovalRequired(String operationCommitment) implements Outcome {
|
||||
/** Validates the safe exact-operation commitment. */
|
||||
public ApprovalRequired {
|
||||
if (operationCommitment == null || !operationCommitment.matches("[0-9a-f]{64}"))
|
||||
throw new IllegalArgumentException("Operation commitment is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final RealmId realmId;
|
||||
private final AuthorityExposurePolicy exposure;
|
||||
private final ServerControlStore control;
|
||||
private final RoleTemplateCatalog roles;
|
||||
private final AuthorizationEngine authorization;
|
||||
private final ApprovalService approvals;
|
||||
private final BreakGlassService breakGlass;
|
||||
private final OperationSecurityDescriptors descriptors;
|
||||
private final PkiOperationExecutor executor;
|
||||
private final PkiResourceScopeResolver resourceScopes;
|
||||
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
|
||||
private final SafeAudit audit;
|
||||
private final Runnable openCheck;
|
||||
|
||||
/** 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,
|
||||
PkiResourceScopeResolver resourceScopes,
|
||||
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies,
|
||||
java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) {
|
||||
this.realmId = Objects.requireNonNull(realmId, "realmId");
|
||||
this.exposure = Objects.requireNonNull(exposure, "exposure");
|
||||
this.control = Objects.requireNonNull(control, "control");
|
||||
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.descriptors = Objects.requireNonNull(descriptors, "descriptors");
|
||||
this.executor = Objects.requireNonNull(executor, "executor");
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits and synchronously executes one operation.
|
||||
*
|
||||
* @param request complete security request
|
||||
* @param cancellation cooperative cancellation signal passed unchanged to PKI
|
||||
* @return safe gateway outcome
|
||||
*/
|
||||
public Outcome execute(Request request, CancellationSignal cancellation) {
|
||||
openCheck.run();
|
||||
Request exact = Objects.requireNonNull(request, "request");
|
||||
Objects.requireNonNull(cancellation, "cancellation");
|
||||
if (!realmId.equals(exact.realmId()) || !realmId.equals(exact.resource().scope().realmId())) {
|
||||
return denied(exact, AuthorizationEngine.Code.OUTSIDE_REALM);
|
||||
}
|
||||
OperationSecurityDescriptors.Descriptor descriptor;
|
||||
try {
|
||||
descriptor = descriptors.require(exact.operation());
|
||||
descriptors.validateResource(exact.operation(), exact.resource());
|
||||
validateAuthoritativeScope(exact.operation(), exact.resource());
|
||||
} catch (SecurityException invalid) {
|
||||
return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
|
||||
}
|
||||
if (exact.operation() instanceof PkiOperation.CreateAuthority && !exposure.authorityCreationPermitted()) {
|
||||
return denied(exact, AuthorizationEngine.Code.OUTSIDE_AUTHORITY_SCOPE);
|
||||
}
|
||||
SecurityPrincipal principal;
|
||||
try {
|
||||
principal = control.requirePrincipal(exact.principalId());
|
||||
} catch (IllegalArgumentException unavailable) {
|
||||
return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
|
||||
}
|
||||
List<Permission.Grant> grants = new ArrayList<>(control.grantsFor(principal.principalId()));
|
||||
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
|
||||
grants.addAll(roles.instantiate(assignment));
|
||||
}
|
||||
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId());
|
||||
grants.addAll(emergency.grants());
|
||||
Permission.Action action = action(exact.operation(), 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
|
||||
&& decision.code() == AuthorizationEngine.Code.NO_MATCHING_GRANT;
|
||||
if (!decision.allowed() && !filterableList) {
|
||||
return denied(exact, decision.code());
|
||||
}
|
||||
if (decision.usedBreakGlass()) breakGlass.auditUse(principal.principalId());
|
||||
String commitment = descriptors.commitment(realmId, exact.operation(), exact.resource());
|
||||
Optional<String> claimedApproval = Optional.empty();
|
||||
if (descriptor.approvalCategory() == OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK) {
|
||||
ApprovalService.Policy expected = approvalPolicies.get(descriptor.approvalCategory());
|
||||
if (expected == null) throw new IllegalStateException("High-risk approval policy is not configured");
|
||||
if (exact.approvalId().isEmpty()) return new Outcome.ApprovalRequired(commitment);
|
||||
ApprovalService.Request current = approvals.requireCurrent(exact.approvalId().orElseThrow());
|
||||
if (!current.policyCommitment().equals(expected.commitment())) {
|
||||
return denied(exact, AuthorizationEngine.Code.APPROVAL_REQUIRED);
|
||||
}
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal,
|
||||
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
|
||||
if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome;
|
||||
PkiOperationValue value = success.result().fields().get("authorities");
|
||||
if (!(value instanceof PkiOperationValue.ListValue list)) return outcome;
|
||||
List<PkiOperationValue> allowed = list.values().stream().filter(item -> authorityAllowed(item, principal,
|
||||
grants, breakGlassIds, context)).toList();
|
||||
Map<String, PkiOperationValue> fields = new LinkedHashMap<>(success.result().fields());
|
||||
fields.put("count", new PkiOperationValue.IntegerValue(allowed.size()));
|
||||
fields.put("authorities", new PkiOperationValue.ListValue(allowed));
|
||||
return new PkiOperationOutcome.Success(new PkiOperationResult(success.result().operationName(), fields));
|
||||
}
|
||||
|
||||
private boolean authorityAllowed(PkiOperationValue value, SecurityPrincipal principal,
|
||||
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
|
||||
if (!(value instanceof PkiOperationValue.ObjectValue object)
|
||||
|| !(object.fields().get("caId") instanceof PkiOperationValue.Text id)) return false;
|
||||
PkiId authorityId = new PkiId(id.value());
|
||||
if (!exposure.allows(authorityId)) return false;
|
||||
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.AUTHORITY,
|
||||
new Permission.Scope(realmId, Optional.of(authorityId), Optional.empty(), Optional.empty()),
|
||||
Optional.of(authorityId), Optional.empty());
|
||||
return authorization.authorize(new AuthorizationEngine.Request(realmId, exposure, principal,
|
||||
Permission.Action.AUTHORITY_LIST, resource, Permission.Relationship.ANY,
|
||||
Permission.DataView.METADATA_REDACTED, context, grants, breakGlassIds)).allowed();
|
||||
}
|
||||
|
||||
private Permission.Context approvalContext(Request request) {
|
||||
return new Permission.Context(request.context().reason(), request.approvalId().isPresent(),
|
||||
request.context().authorityActive(), request.context().safeAttributes());
|
||||
}
|
||||
|
||||
private void validateAuthoritativeScope(PkiOperation operation, Permission.Resource resource) {
|
||||
Optional<PkiId> actual = switch (operation) {
|
||||
case PkiOperation.InspectCredential value -> resourceScopes.credentialAuthority(value.credentialId());
|
||||
case PkiOperation.RevokeCredential value -> resourceScopes.credentialAuthority(value.credentialId());
|
||||
case PkiOperation.ReadRevocationHistory value -> resourceScopes.credentialAuthority(value.credentialId());
|
||||
case PkiOperation.InspectPublication value -> resourceScopes.publicationAuthority(value.publicationId());
|
||||
case PkiOperation.ProcessPublication value -> resourceScopes.publicationAuthority(value.publicationId());
|
||||
default -> resource.scope().authorityId();
|
||||
};
|
||||
if (requiresResolvedAuthority(operation)
|
||||
&& (actual.isEmpty() || !actual.equals(resource.scope().authorityId()))) {
|
||||
throw new SecurityException("Authoritative object scope does not match the request");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean requiresResolvedAuthority(PkiOperation operation) {
|
||||
return operation instanceof PkiOperation.InspectCredential
|
||||
|| operation instanceof PkiOperation.RevokeCredential
|
||||
|| operation instanceof PkiOperation.ReadRevocationHistory
|
||||
|| operation instanceof PkiOperation.InspectPublication
|
||||
|| operation instanceof PkiOperation.ProcessPublication;
|
||||
}
|
||||
|
||||
private Outcome denied(Request request, AuthorizationEngine.Code code) {
|
||||
audit.record("AUTHORIZATION_DENY", request.principalId(), Optional.empty(),
|
||||
Map.of("operation", request.operation().name(), "code", code.name(),
|
||||
"correlation", request.correlationId()));
|
||||
return new Outcome.Denied(code);
|
||||
}
|
||||
|
||||
private static Permission.Action action(PkiOperation operation, Permission.Action defaultAction) {
|
||||
if (operation instanceof PkiOperation.TransitionAuthority transition) {
|
||||
return switch (transition.state()) {
|
||||
case ACTIVE -> Permission.Action.AUTHORITY_ACTIVATE;
|
||||
case RETIRED -> Permission.Action.AUTHORITY_RETIRE;
|
||||
case COMPROMISED, DISABLED -> Permission.Action.AUTHORITY_SUSPEND;
|
||||
};
|
||||
}
|
||||
return defaultAction;
|
||||
}
|
||||
|
||||
private static String classification(PkiOperationOutcome outcome) {
|
||||
return switch (outcome) {
|
||||
case PkiOperationOutcome.Success ignored -> "SUCCEEDED";
|
||||
case PkiOperationOutcome.Failure failure -> failure.classification().name();
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user