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,332 @@
|
||||
/*******************************************************************************
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Durable exact-operation multi-party approval service. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
|
||||
"PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" })
|
||||
public final class ApprovalService {
|
||||
/** Approval lifecycle states. */
|
||||
public enum State {
|
||||
PENDING(1), APPROVED(2), REJECTED(3), EXPIRED(4), CANCELLED(5), EXECUTING(6), EXECUTED(7);
|
||||
private final int code;
|
||||
State(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static State fromCode(int code) {
|
||||
for (State candidate : values()) if (candidate.code == code) return candidate;
|
||||
throw new IllegalArgumentException("Unknown approval-state code");
|
||||
}
|
||||
}
|
||||
|
||||
/** Individual immutable approval choice. */
|
||||
public enum Choice {
|
||||
APPROVE(1), REJECT(2);
|
||||
private final int code;
|
||||
Choice(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static Choice fromCode(int code) {
|
||||
return switch (code) { case 1 -> APPROVE; case 2 -> REJECT;
|
||||
default -> throw new IllegalArgumentException("Unknown approval-choice code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable approval policy.
|
||||
*
|
||||
* @param policyId stable policy identity
|
||||
* @param threshold required distinct approvals
|
||||
* @param eligibleApprovers finite M-set, empty only when a required role defines eligibility
|
||||
* @param requiredRoleTemplateIds eligible approver role-template IDs
|
||||
* @param requesterSeparation whether the requester is barred from approving
|
||||
* @param lifetime positive approval lifetime
|
||||
* @param justificationRequired whether a nonblank justification is mandatory
|
||||
*/
|
||||
public record Policy(String policyId, int threshold, Set<String> eligibleApprovers,
|
||||
Set<String> requiredRoleTemplateIds, boolean requesterSeparation, Duration lifetime,
|
||||
boolean justificationRequired) {
|
||||
/** Validates the finite N-of-M policy. */
|
||||
public Policy {
|
||||
Permission.requireId(policyId, "approval policy");
|
||||
eligibleApprovers = Set.copyOf(Objects.requireNonNull(eligibleApprovers, "eligibleApprovers"));
|
||||
requiredRoleTemplateIds = Set.copyOf(
|
||||
Objects.requireNonNull(requiredRoleTemplateIds, "requiredRoleTemplateIds"));
|
||||
eligibleApprovers.forEach(Permission::requirePrincipal);
|
||||
requiredRoleTemplateIds.forEach(value -> Permission.requireId(value, "role template"));
|
||||
if (threshold <= 0 || eligibleApprovers.isEmpty() && requiredRoleTemplateIds.isEmpty()
|
||||
|| !eligibleApprovers.isEmpty() && threshold > eligibleApprovers.size()) {
|
||||
throw new IllegalArgumentException("Approval threshold or eligibility set is invalid");
|
||||
}
|
||||
Objects.requireNonNull(lifetime, "lifetime");
|
||||
if (lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofDays(30)) > 0) {
|
||||
throw new IllegalArgumentException("Approval lifetime is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a stable commitment for this policy. */
|
||||
public String commitment() {
|
||||
List<String> approvers = eligibleApprovers.stream().sorted().toList();
|
||||
List<String> roles = requiredRoleTemplateIds.stream().sorted().toList();
|
||||
return digest(policyId + '|' + threshold + '|' + String.join(",", approvers) + '|'
|
||||
+ String.join(",", roles) + '|' + requesterSeparation + '|' + lifetime.toSeconds()
|
||||
+ '|' + justificationRequired);
|
||||
}
|
||||
}
|
||||
|
||||
/** One attributable immutable decision. */
|
||||
public record Decision(String principalId, Choice choice, String justification, Instant decidedAt) {
|
||||
/** Validates the decision. */
|
||||
public Decision {
|
||||
Permission.requirePrincipal(principalId);
|
||||
Objects.requireNonNull(choice, "choice");
|
||||
Permission.requireBounded(justification, 2048, "justification");
|
||||
Objects.requireNonNull(decidedAt, "decidedAt");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable approval authority record.
|
||||
*
|
||||
* @param approvalId stable record identity
|
||||
* @param operationId stable typed operation name
|
||||
* @param operationCommitment exact safe operation commitment
|
||||
* @param scope exact realm/authority/issuer/profile scope
|
||||
* @param requesterPrincipalId requester identity
|
||||
* @param createdAt creation time
|
||||
* @param expiresAt expiry time
|
||||
* @param policy approval policy
|
||||
* @param policyCommitment exact policy commitment
|
||||
* @param state lifecycle state
|
||||
* @param decisions immutable attributable decisions
|
||||
* @param resultClassification safe linked execution classification
|
||||
*/
|
||||
public record Request(String approvalId, String operationId, String operationCommitment,
|
||||
Permission.Scope scope, String requesterPrincipalId, Instant createdAt, Instant expiresAt,
|
||||
Policy policy, String policyCommitment, State state, List<Decision> decisions,
|
||||
Optional<String> resultClassification) {
|
||||
/** Validates and snapshots the durable record. */
|
||||
public Request {
|
||||
Permission.requireId(approvalId, "approval");
|
||||
Permission.requireBounded(operationId, 256, "operation ID");
|
||||
requireDigest(operationCommitment);
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
Permission.requirePrincipal(requesterPrincipalId);
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(createdAt)) throw new IllegalArgumentException("Approval expiry is invalid");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
requireDigest(policyCommitment);
|
||||
if (!policy.commitment().equals(policyCommitment)) {
|
||||
throw new IllegalArgumentException("Approval-policy commitment mismatch");
|
||||
}
|
||||
Objects.requireNonNull(state, "state");
|
||||
decisions = List.copyOf(Objects.requireNonNull(decisions, "decisions"));
|
||||
resultClassification = Objects.requireNonNull(resultClassification, "resultClassification")
|
||||
.map(value -> Permission.requireBounded(value, 256, "result classification"));
|
||||
if (state == State.EXECUTED != resultClassification.isPresent()) {
|
||||
throw new IllegalArgumentException("Executed approval result linkage is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final ServerControlStore store;
|
||||
private final Clock clock;
|
||||
private final SafeAudit audit;
|
||||
|
||||
/** Creates a durable approval service over one server-control authority. */
|
||||
public ApprovalService(ServerControlStore store, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
}
|
||||
|
||||
/** Creates a new pending exact-operation request. */
|
||||
public synchronized Request request(String approvalId, String operationId, String operationCommitment,
|
||||
Permission.Scope scope, String requesterPrincipalId, Policy policy) {
|
||||
store.requirePrincipal(requesterPrincipalId);
|
||||
Instant now = clock.instant();
|
||||
Request request = new Request(approvalId, operationId, operationCommitment, scope, requesterPrincipalId,
|
||||
now, now.plus(policy.lifetime()), policy, policy.commitment(), State.PENDING, List.of(),
|
||||
Optional.empty());
|
||||
store.createApproval(request);
|
||||
audit.record("APPROVAL_REQUEST", requesterPrincipalId, Optional.empty(), Map.of("state", "PENDING"));
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Records one distinct qualified decision and advances the durable state. */
|
||||
public synchronized Request decide(String approvalId, String principalId,
|
||||
Choice choice, String justification) {
|
||||
SecurityPrincipal principal = store.requirePrincipal(principalId);
|
||||
Request current = requireCurrent(approvalId);
|
||||
requirePending(current);
|
||||
if (!principal.enabled() || current.policy().requesterSeparation()
|
||||
&& current.requesterPrincipalId().equals(principal.principalId())) {
|
||||
throw new IllegalStateException("Principal is not eligible to decide this approval");
|
||||
}
|
||||
boolean direct = current.policy().eligibleApprovers().contains(principal.principalId());
|
||||
boolean role = store.assignmentsFor(principal.principalId()).stream()
|
||||
.filter(RoleTemplateCatalog.Assignment::enabled)
|
||||
.filter(assignment -> contains(assignment.scope(), current.scope()))
|
||||
.map(RoleTemplateCatalog.Assignment::templateId)
|
||||
.anyMatch(current.policy().requiredRoleTemplateIds()::contains);
|
||||
if (!direct && !role) throw new IllegalStateException("Principal is not an eligible approver");
|
||||
if (current.decisions().stream().anyMatch(item -> item.principalId().equals(principal.principalId()))) {
|
||||
throw new IllegalStateException("Principal has already decided this approval");
|
||||
}
|
||||
if (current.policy().justificationRequired() && (justification == null || justification.isBlank())) {
|
||||
throw new IllegalArgumentException("Approval justification is required");
|
||||
}
|
||||
List<Decision> decisions = new ArrayList<>(current.decisions());
|
||||
decisions.add(new Decision(principal.principalId(), choice,
|
||||
Permission.requireBounded(justification, 2048, "justification"), clock.instant()));
|
||||
decisions.sort(Comparator.comparing(Decision::decidedAt).thenComparing(Decision::principalId));
|
||||
State state = choice == Choice.REJECT ? State.REJECTED
|
||||
: decisions.stream().filter(item -> item.choice() == Choice.APPROVE).count()
|
||||
>= current.policy().threshold() ? State.APPROVED : State.PENDING;
|
||||
Request updated = copy(current, state, decisions, Optional.empty());
|
||||
store.replaceApproval(current, updated);
|
||||
audit.record("APPROVAL_DECISION", principal.principalId(), Optional.empty(), Map.of("state", state.name()));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Cancels a pending request by its requester. */
|
||||
public synchronized Request cancel(String approvalId, String requesterPrincipalId) {
|
||||
Request current = requireCurrent(approvalId);
|
||||
requirePending(current);
|
||||
if (!current.requesterPrincipalId().equals(requesterPrincipalId)) {
|
||||
throw new IllegalStateException("Only the requester may cancel a pending approval");
|
||||
}
|
||||
Request updated = copy(current, State.CANCELLED, current.decisions(), Optional.empty());
|
||||
store.replaceApproval(current, updated);
|
||||
audit.record("APPROVAL_CANCEL", requesterPrincipalId, Optional.empty(), Map.of("state", "CANCELLED"));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Validates and durably claims an approved operation before dispatch. */
|
||||
public synchronized Request claim(String approvalId, String operationId, String operationCommitment,
|
||||
Permission.Scope scope) {
|
||||
Request current = requireCurrent(approvalId);
|
||||
if (!current.operationId().equals(operationId) || !current.operationCommitment().equals(operationCommitment)
|
||||
|| !current.scope().equals(scope)) {
|
||||
throw new IllegalStateException("Approval does not bind the requested operation");
|
||||
}
|
||||
current = expireIfNecessary(current);
|
||||
if (current.state() != State.APPROVED) throw new IllegalStateException("Approval is not executable");
|
||||
Request executing = copy(current, State.EXECUTING, current.decisions(), Optional.empty());
|
||||
store.replaceApproval(current, executing);
|
||||
return executing;
|
||||
}
|
||||
|
||||
/** Durably links the safe result of one claimed execution. */
|
||||
public synchronized Request complete(String approvalId, String safeResultClassification) {
|
||||
Request current = requireCurrent(approvalId);
|
||||
if (current.state() != State.EXECUTING) throw new IllegalStateException("Approval is not executing");
|
||||
Request executed = copy(current, State.EXECUTED, current.decisions(),
|
||||
Optional.of(Permission.requireBounded(safeResultClassification, 256, "result classification")));
|
||||
store.replaceApproval(current, executed);
|
||||
audit.record("APPROVAL_EXECUTION", current.requesterPrincipalId(), Optional.empty(),
|
||||
Map.of("result", safeResultClassification));
|
||||
return executed;
|
||||
}
|
||||
|
||||
/** Returns the current record, applying durable expiry when necessary. */
|
||||
public synchronized Request requireCurrent(String approvalId) {
|
||||
Request current = store.requireApproval(approvalId);
|
||||
return expireIfNecessary(current);
|
||||
}
|
||||
|
||||
private Request expireIfNecessary(Request current) {
|
||||
if ((current.state() == State.PENDING || current.state() == State.APPROVED)
|
||||
&& !clock.instant().isBefore(current.expiresAt())) {
|
||||
Request expired = copy(current, State.EXPIRED, current.decisions(), Optional.empty());
|
||||
store.replaceApproval(current, expired);
|
||||
audit.record("APPROVAL_EXPIRY", current.requesterPrincipalId(), Optional.empty(),
|
||||
Map.of("state", "EXPIRED"));
|
||||
return expired;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static void requirePending(Request request) {
|
||||
if (request.state() != State.PENDING) throw new IllegalStateException("Approval is not pending");
|
||||
}
|
||||
|
||||
private static boolean contains(Permission.Scope grant, Permission.Scope request) {
|
||||
return grant.realmId().equals(request.realmId())
|
||||
&& grant.authorityId().map(value -> request.authorityId().filter(value::equals).isPresent())
|
||||
.orElse(true)
|
||||
&& grant.issuerId().map(value -> request.issuerId().filter(value::equals).isPresent()).orElse(true)
|
||||
&& grant.profileId().map(value -> request.profileId().filter(value::equals).isPresent()).orElse(true);
|
||||
}
|
||||
|
||||
private static Request copy(Request source, State state, List<Decision> decisions,
|
||||
Optional<String> result) {
|
||||
return new Request(source.approvalId(), source.operationId(), source.operationCommitment(), source.scope(),
|
||||
source.requesterPrincipalId(), source.createdAt(), source.expiresAt(), source.policy(),
|
||||
source.policyCommitment(), state, decisions, result);
|
||||
}
|
||||
|
||||
/** Computes a deterministic SHA-256 hexadecimal commitment. */
|
||||
public static String digest(String canonical) {
|
||||
Objects.requireNonNull(canonical, "canonical");
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDigest(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Commitment is not canonical SHA-256");
|
||||
}
|
||||
}
|
||||
}
|
||||
143
pki-server/src/main/java/zeroecho/pki/server/AuditorViews.java
Normal file
143
pki-server/src/main/java/zeroecho/pki/server/AuditorViews.java
Normal file
@@ -0,0 +1,143 @@
|
||||
/*******************************************************************************
|
||||
* 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.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Explicit certificate/request/status projection boundary for auditor data views. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
|
||||
public final class AuditorViews {
|
||||
/** Safe source metadata from which authorized projections are produced. */
|
||||
public record Source(PkiId objectId, Permission.ResourceType type, Permission.Scope scope, String lifecycleState,
|
||||
Instant createdAt, String algorithmId, Optional<String> bindingId, String commitment,
|
||||
boolean subjectPresent, int subjectAlternativeNameCount, Optional<String> fullSubject,
|
||||
java.util.List<String> fullSubjectAlternativeNames, Optional<byte[]> content) {
|
||||
/** Validates and snapshots finite source data. */
|
||||
public Source {
|
||||
Objects.requireNonNull(objectId, "objectId");
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
Permission.requireBounded(lifecycleState, 128, "lifecycle state");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Permission.requireBounded(algorithmId, 1024, "algorithm identity");
|
||||
bindingId = Objects.requireNonNull(bindingId, "bindingId");
|
||||
if (commitment == null || !commitment.matches("[0-9a-f]{64}"))
|
||||
throw new IllegalArgumentException("Object commitment is invalid");
|
||||
if (subjectAlternativeNameCount < 0) throw new IllegalArgumentException("SAN count is negative");
|
||||
fullSubject = Objects.requireNonNull(fullSubject, "fullSubject");
|
||||
fullSubjectAlternativeNames = java.util.List.copyOf(
|
||||
Objects.requireNonNull(fullSubjectAlternativeNames, "fullSubjectAlternativeNames"));
|
||||
content = Objects.requireNonNull(content, "content").map(byte[]::clone);
|
||||
}
|
||||
}
|
||||
|
||||
/** Redacted metadata projection containing indicators rather than PII. */
|
||||
public record Redacted(PkiId objectId, Permission.ResourceType type, Permission.Scope scope,
|
||||
String lifecycleState, Instant createdAt, String algorithmId, Optional<String> bindingId,
|
||||
String commitment, boolean subjectPresent, int subjectAlternativeNameCount) { }
|
||||
|
||||
/** Full non-PII metadata projection. */
|
||||
public record FullMetadata(Redacted redacted, Map<String, String> safeMetadata) {
|
||||
/** Defensively snapshots safe metadata. */
|
||||
public FullMetadata {
|
||||
Objects.requireNonNull(redacted, "redacted");
|
||||
safeMetadata = Map.copyOf(Objects.requireNonNull(safeMetadata, "safeMetadata"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Full binary content projection; byte content is never redacted in place. */
|
||||
public record FullContent(FullMetadata metadata, byte[] content) {
|
||||
/** Defensively snapshots content. */
|
||||
public FullContent {
|
||||
Objects.requireNonNull(metadata, "metadata");
|
||||
content = Objects.requireNonNull(content, "content").clone();
|
||||
}
|
||||
/** Returns a defensive content copy. */ @Override public byte[] content() { return content.clone(); }
|
||||
}
|
||||
|
||||
/** Explicit PII-enabled projection, separately authorized from content. */
|
||||
public record Pii(FullMetadata metadata, Optional<String> subject, java.util.List<String> subjectAlternativeNames) {
|
||||
/** Defensively snapshots PII data. */
|
||||
public Pii {
|
||||
Objects.requireNonNull(metadata, "metadata");
|
||||
subject = Objects.requireNonNull(subject, "subject");
|
||||
subjectAlternativeNames = java.util.List.copyOf(
|
||||
Objects.requireNonNull(subjectAlternativeNames, "subjectAlternativeNames"));
|
||||
}
|
||||
}
|
||||
|
||||
private final SafeAudit audit;
|
||||
|
||||
/** Creates an auditor-view boundary using an injected clock and audit sink. */
|
||||
public AuditorViews(Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
}
|
||||
|
||||
/** Returns a standard redacted view with no full subject, SAN, or DER. */
|
||||
public Redacted redacted(Source source) {
|
||||
Source exact = Objects.requireNonNull(source, "source");
|
||||
return new Redacted(exact.objectId(), exact.type(), exact.scope(), exact.lifecycleState(), exact.createdAt(),
|
||||
exact.algorithmId(), exact.bindingId(), exact.commitment(), exact.subjectPresent(),
|
||||
exact.subjectAlternativeNameCount());
|
||||
}
|
||||
|
||||
/** Returns full non-PII metadata after explicit authorization by the caller. */
|
||||
public FullMetadata fullMetadata(Source source, boolean authorized) {
|
||||
if (!authorized) throw new SecurityException("Auditor view is not authorized");
|
||||
return new FullMetadata(redacted(source), Map.of("view", "METADATA_FULL"));
|
||||
}
|
||||
|
||||
/** Returns full binary content only with separate content permission. */
|
||||
public FullContent fullContent(Source source, boolean metadataFullAuthorized, boolean contentAuthorized) {
|
||||
if (!contentAuthorized || source.content().isEmpty()) throw new SecurityException("Content view is unavailable");
|
||||
return new FullContent(fullMetadata(source, metadataFullAuthorized), source.content().orElseThrow());
|
||||
}
|
||||
|
||||
/** Returns PII only with explicit reason-bound authorization and records access safely. */
|
||||
public Pii pii(Source source, SecurityPrincipal principal, String reasonReference,
|
||||
boolean metadataFullAuthorized, boolean piiAuthorized) {
|
||||
if (!metadataFullAuthorized || !piiAuthorized) throw new SecurityException("PII view is not authorized");
|
||||
Permission.requireBounded(reasonReference, 256, "PII reason reference");
|
||||
audit.record("PII_VIEW", principal.principalId(), Optional.of(source.objectId()),
|
||||
Map.of("reasonReference", reasonReference));
|
||||
return new Pii(fullMetadata(source, metadataFullAuthorized), source.fullSubject(),
|
||||
source.fullSubjectAlternativeNames());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*******************************************************************************
|
||||
* 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 java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/**
|
||||
* Immutable process policy controlling which logical authorities one realm
|
||||
* context may expose.
|
||||
*
|
||||
* @param mode exposure mode
|
||||
* @param authorityIds exact exposed authorities for explicit mode
|
||||
* @param authorityCreationPermitted whether realm-scoped authority creation is permitted
|
||||
*/
|
||||
public record AuthorityExposurePolicy(Mode mode, Set<PkiId> authorityIds,
|
||||
boolean authorityCreationPermitted) {
|
||||
/** Closed authority-exposure modes. */
|
||||
public enum Mode {
|
||||
/** Every authority in the realm session is eligible for scoped exposure. */
|
||||
ALL_REALM_AUTHORITIES(1),
|
||||
/** Only the exact configured authority identities are eligible. */
|
||||
EXPLICIT_AUTHORITIES(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
Mode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/** @return stable persistence code */
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Resolves one stable persistence code. */
|
||||
public static Mode fromCode(int code) {
|
||||
return switch (code) {
|
||||
case 1 -> ALL_REALM_AUTHORITIES;
|
||||
case 2 -> EXPLICIT_AUTHORITIES;
|
||||
default -> throw new IllegalArgumentException("Unknown authority exposure code");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates and defensively snapshots the exposure policy. */
|
||||
public AuthorityExposurePolicy {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
authorityIds = Set.copyOf(Objects.requireNonNull(authorityIds, "authorityIds"));
|
||||
if (mode == Mode.ALL_REALM_AUTHORITIES && !authorityIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("All-authority mode cannot carry an explicit set");
|
||||
}
|
||||
if (mode == Mode.EXPLICIT_AUTHORITIES && authorityIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("Explicit authority mode requires at least one authority");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests process exposure for one canonical authority.
|
||||
*
|
||||
* @param authorityId authority to test
|
||||
* @return whether the process may expose it
|
||||
*/
|
||||
public boolean allows(PkiId authorityId) {
|
||||
Objects.requireNonNull(authorityId, "authorityId");
|
||||
return mode == Mode.ALL_REALM_AUTHORITIES || authorityIds.contains(authorityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*******************************************************************************
|
||||
* 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.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Deterministic side-effect-free default-deny authorization engine.
|
||||
*
|
||||
* <p>Evaluation is linear in the number of supplied grants and uses constant
|
||||
* auxiliary memory. Callers supply an immutable snapshot of assignments for one
|
||||
* decision; the engine never reads persistence or invokes external code.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
|
||||
public final class AuthorizationEngine {
|
||||
/** Safe decision codes that reveal no protected-object existence. */
|
||||
public enum Code {
|
||||
ALLOWED, NO_MATCHING_GRANT, EXPLICITLY_DENIED, OUTSIDE_REALM,
|
||||
OUTSIDE_AUTHORITY_SCOPE, INSUFFICIENT_DATA_VIEW, APPROVAL_REQUIRED,
|
||||
GRANT_EXPIRED, PRINCIPAL_DISABLED, CONDITION_NOT_SATISFIED
|
||||
}
|
||||
|
||||
/**
|
||||
* One complete authorization input.
|
||||
*
|
||||
* @param realmId active realm identity
|
||||
* @param exposure process authority-exposure policy
|
||||
* @param principal authenticated or public principal
|
||||
* @param action requested action
|
||||
* @param resource exact target reference
|
||||
* @param relationship established object relationship
|
||||
* @param dataView requested view
|
||||
* @param context safe closed-condition context
|
||||
* @param grants enabled assignments and grants captured for this decision
|
||||
* @param breakGlassGrantIds grant identities originating from active break-glass records
|
||||
*/
|
||||
public record Request(RealmId realmId, AuthorityExposurePolicy exposure, SecurityPrincipal principal,
|
||||
Permission.Action action, Permission.Resource resource, Permission.Relationship relationship,
|
||||
Permission.DataView dataView, Permission.Context context, List<Permission.Grant> grants,
|
||||
java.util.Set<String> breakGlassGrantIds) {
|
||||
/** Validates and snapshots the authorization input. */
|
||||
public Request {
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Objects.requireNonNull(exposure, "exposure");
|
||||
Objects.requireNonNull(principal, "principal");
|
||||
Objects.requireNonNull(action, "action");
|
||||
Objects.requireNonNull(resource, "resource");
|
||||
Objects.requireNonNull(relationship, "relationship");
|
||||
Objects.requireNonNull(dataView, "dataView");
|
||||
Objects.requireNonNull(context, "context");
|
||||
grants = List.copyOf(Objects.requireNonNull(grants, "grants"));
|
||||
breakGlassGrantIds = java.util.Set.copyOf(
|
||||
Objects.requireNonNull(breakGlassGrantIds, "breakGlassGrantIds"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finite safe authorization result.
|
||||
*
|
||||
* @param code decision code
|
||||
* @param usedBreakGlass whether the allowing grant came from break-glass authority
|
||||
*/
|
||||
public record Decision(Code code, boolean usedBreakGlass) {
|
||||
/** Validates result invariants. */
|
||||
public Decision {
|
||||
Objects.requireNonNull(code, "code");
|
||||
if (code != Code.ALLOWED && usedBreakGlass) {
|
||||
throw new IllegalArgumentException("Denied decisions cannot report break-glass use");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return whether execution is authorized */
|
||||
public boolean allowed() {
|
||||
return code == Code.ALLOWED;
|
||||
}
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
/**
|
||||
* Creates an engine using an injected authoritative decision clock.
|
||||
*
|
||||
* @param clock injected clock
|
||||
*/
|
||||
public AuthorizationEngine(Clock clock) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates one request with explicit-deny precedence.
|
||||
*
|
||||
* @param request complete immutable input
|
||||
* @return safe finite decision
|
||||
*/
|
||||
public Decision authorize(Request request) {
|
||||
Request exact = Objects.requireNonNull(request, "request");
|
||||
if (!exact.realmId().equals(exact.resource().scope().realmId())) {
|
||||
return denied(Code.OUTSIDE_REALM);
|
||||
}
|
||||
if (!exact.principal().enabled()) {
|
||||
return denied(Code.PRINCIPAL_DISABLED);
|
||||
}
|
||||
if (exact.resource().scope().authorityId().isPresent()
|
||||
&& !exact.exposure().allows(exact.resource().scope().authorityId().orElseThrow())) {
|
||||
return denied(Code.OUTSIDE_AUTHORITY_SCOPE);
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
boolean matchingExpired = false;
|
||||
boolean matchingView = false;
|
||||
boolean matchingCondition = false;
|
||||
boolean approvalMissing = false;
|
||||
Permission.Grant allowed = null;
|
||||
for (Permission.Grant grant : exact.grants()) {
|
||||
if (!basicMatch(exact, grant)) {
|
||||
continue;
|
||||
}
|
||||
if (grant.expiresAt().filter(expiry -> !now.isBefore(expiry)).isPresent()) {
|
||||
matchingExpired = true;
|
||||
continue;
|
||||
}
|
||||
if (!grant.dataView().permits(exact.dataView())) {
|
||||
matchingView = true;
|
||||
continue;
|
||||
}
|
||||
if (!conditionsMatch(grant, exact)) {
|
||||
matchingCondition = true;
|
||||
approvalMissing |= grant.conditions().contains(Permission.Condition.APPROVAL_REQUIRED)
|
||||
&& !exact.context().approvalPresent();
|
||||
continue;
|
||||
}
|
||||
if (grant.effect() == Permission.Effect.DENY) {
|
||||
return denied(Code.EXPLICITLY_DENIED);
|
||||
}
|
||||
allowed = grant;
|
||||
}
|
||||
if (allowed != null) {
|
||||
return new Decision(Code.ALLOWED, exact.breakGlassGrantIds().contains(allowed.grantId()));
|
||||
}
|
||||
if (matchingExpired) return denied(Code.GRANT_EXPIRED);
|
||||
if (matchingView) return denied(Code.INSUFFICIENT_DATA_VIEW);
|
||||
if (approvalMissing) return denied(Code.APPROVAL_REQUIRED);
|
||||
if (matchingCondition) return denied(Code.CONDITION_NOT_SATISFIED);
|
||||
return denied(Code.NO_MATCHING_GRANT);
|
||||
}
|
||||
|
||||
private static boolean basicMatch(Request request, Permission.Grant grant) {
|
||||
if (!grant.enabled() || !grant.principalId().equals(request.principal().principalId())
|
||||
|| grant.action() != request.action() || grant.resourceType() != request.resource().type()
|
||||
|| !grant.scope().realmId().equals(request.realmId())) {
|
||||
return false;
|
||||
}
|
||||
if (grant.scope().authorityId().isPresent()
|
||||
&& !grant.scope().authorityId().equals(request.resource().scope().authorityId())) return false;
|
||||
if (grant.scope().issuerId().isPresent()
|
||||
&& !grant.scope().issuerId().equals(request.resource().scope().issuerId())) return false;
|
||||
if (grant.scope().profileId().isPresent()
|
||||
&& !grant.scope().profileId().equals(request.resource().scope().profileId())) return false;
|
||||
return grant.relationship() == Permission.Relationship.ANY
|
||||
|| request.relationship() == Permission.Relationship.OWN
|
||||
&& request.resource().ownedBy(request.principal());
|
||||
}
|
||||
|
||||
private static boolean conditionsMatch(Permission.Grant grant, Request request) {
|
||||
for (Permission.Condition condition : grant.conditions()) {
|
||||
boolean matches = switch (condition) {
|
||||
case REASON_REQUIRED -> request.context().reason().isPresent();
|
||||
case APPROVAL_REQUIRED -> request.context().approvalPresent();
|
||||
case REQUESTER_APPROVER_SEPARATION -> request.context().safeAttributes()
|
||||
.getOrDefault("requester", "").isBlank()
|
||||
|| !request.principal().principalId().equals(
|
||||
request.context().safeAttributes().get("requester"));
|
||||
case AUTHORITY_ACTIVE -> request.context().authorityActive();
|
||||
case PROFILE_MATCH_REQUIRED -> request.resource().scope().profileId().isPresent();
|
||||
};
|
||||
if (!matches) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Decision denied(Code code) {
|
||||
return new Decision(code, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*******************************************************************************
|
||||
* 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.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable, scoped, mandatory-expiry emergency authorization service. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
|
||||
"PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" })
|
||||
public final class BreakGlassService {
|
||||
/** Break-glass lifecycle states. */
|
||||
public enum State {
|
||||
ACTIVE(1), REVOKED(2), EXPIRED(3);
|
||||
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;
|
||||
default -> throw new IllegalArgumentException("Unknown break-glass state code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable break-glass record.
|
||||
*
|
||||
* @param breakGlassId stable emergency grant identity
|
||||
* @param principalId grantee
|
||||
* @param grant exact scoped permission grant
|
||||
* @param reason mandatory reason
|
||||
* @param issuerPrincipalId independent issuer identity
|
||||
* @param approvalId optional independent approval authority
|
||||
* @param createdAt creation time
|
||||
* @param activatedAt activation time
|
||||
* @param expiresAt mandatory expiry
|
||||
* @param state lifecycle state
|
||||
* @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,
|
||||
Instant expiresAt, State state, String auditCommitment) {
|
||||
/** Validates the strict time-limited scoped record. */
|
||||
public Record {
|
||||
Permission.requireId(breakGlassId, "break-glass");
|
||||
Permission.requirePrincipal(principalId);
|
||||
Objects.requireNonNull(grant, "grant");
|
||||
if (!grant.principalId().equals(principalId) || grant.effect() != Permission.Effect.ALLOW) {
|
||||
throw new IllegalArgumentException("Break-glass grant must be an explicit scoped allow");
|
||||
}
|
||||
Permission.requireBounded(reason, 2048, "break-glass reason");
|
||||
Permission.requirePrincipal(issuerPrincipalId);
|
||||
if (issuerPrincipalId.equals(principalId)) {
|
||||
throw new IllegalArgumentException("Break-glass issuer and grantee must differ");
|
||||
}
|
||||
approvalId = Objects.requireNonNull(approvalId, "approvalId");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(activatedAt, "activatedAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (activatedAt.isBefore(createdAt) || !expiresAt.isAfter(activatedAt)
|
||||
|| Duration.between(activatedAt, expiresAt).compareTo(Duration.ofHours(24)) > 0) {
|
||||
throw new IllegalArgumentException("Break-glass lifetime is invalid");
|
||||
}
|
||||
Objects.requireNonNull(state, "state");
|
||||
if (auditCommitment == null || !auditCommitment.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Break-glass audit commitment is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Active grants plus their identities for authorization and use auditing. */
|
||||
public record ActiveGrants(List<Permission.Grant> grants, java.util.Set<String> grantIds) {
|
||||
/** Defensively snapshots active grants. */
|
||||
public ActiveGrants {
|
||||
grants = List.copyOf(Objects.requireNonNull(grants, "grants"));
|
||||
grantIds = java.util.Set.copyOf(Objects.requireNonNull(grantIds, "grantIds"));
|
||||
}
|
||||
}
|
||||
|
||||
private final ServerControlStore store;
|
||||
private final Clock clock;
|
||||
private final SafeAudit audit;
|
||||
|
||||
/** Creates a durable break-glass service. */
|
||||
public BreakGlassService(ServerControlStore store, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
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,
|
||||
String issuerPrincipalId, Optional<String> approvalId, Instant expiresAt) {
|
||||
store.requirePrincipal(principalId);
|
||||
store.requirePrincipal(issuerPrincipalId);
|
||||
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);
|
||||
store.createBreakGlass(record);
|
||||
audit.record("BREAK_GLASS_ACTIVATE", issuerPrincipalId, Optional.empty(), Map.of("state", "ACTIVE"));
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Revokes an active grant. */
|
||||
public synchronized Record revoke(String breakGlassId, String actorPrincipalId) {
|
||||
Record current = requireCurrent(breakGlassId);
|
||||
if (current.state() != State.ACTIVE) throw new IllegalStateException("Break-glass grant is not active");
|
||||
Record revoked = copy(current, State.REVOKED);
|
||||
store.replaceBreakGlass(current, revoked);
|
||||
audit.record("BREAK_GLASS_REVOKE", actorPrincipalId, Optional.empty(), Map.of("state", "REVOKED"));
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/** 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())) {
|
||||
Record expired = copy(current, State.EXPIRED);
|
||||
store.replaceBreakGlass(current, expired);
|
||||
audit.record("BREAK_GLASS_EXPIRY", current.principalId(), Optional.empty(), Map.of("state", "EXPIRED"));
|
||||
return expired;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Returns current active grants for one principal and enforces expiry after reopen. */
|
||||
public synchronized ActiveGrants activeFor(String principalId) {
|
||||
Permission.requirePrincipal(principalId);
|
||||
List<Record> active = store.breakGlassFor(principalId).stream().map(item -> requireCurrent(item.breakGlassId()))
|
||||
.filter(item -> item.state() == State.ACTIVE).toList();
|
||||
return new ActiveGrants(active.stream().map(Record::grant).toList(),
|
||||
active.stream().map(item -> item.grant().grantId()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
/** Records use without exposing grant internals or reason. */
|
||||
public void auditUse(String principalId) {
|
||||
audit.record("BREAK_GLASS_USE", principalId, Optional.empty(), Map.of("used", "true"));
|
||||
}
|
||||
|
||||
private static Record copy(Record source, State state) {
|
||||
return new Record(source.breakGlassId(), source.principalId(), source.grant(), source.reason(),
|
||||
source.issuerPrincipalId(), source.approvalId(), source.createdAt(), source.activatedAt(),
|
||||
source.expiresAt(), state, source.auditCommitment());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/*******************************************************************************
|
||||
* 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.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Durable disclosure authority and capability-based retrieval decision service. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
|
||||
public final class DisclosureService {
|
||||
/** 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);
|
||||
private final int code;
|
||||
Policy(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static Policy fromCode(int code) {
|
||||
for (Policy candidate : values()) if (candidate.code == code) return candidate;
|
||||
throw new IllegalArgumentException("Unknown disclosure-policy code");
|
||||
}
|
||||
}
|
||||
|
||||
/** Public-object classes with independent default policies. */
|
||||
public enum ObjectType {
|
||||
CA_CERTIFICATE(1), CA_CHAIN(2), LEAF_CERTIFICATE(3), CRL(4), STATUS_OBJECT(5);
|
||||
private final int code;
|
||||
ObjectType(int code) { this.code = code; }
|
||||
/** @return stable persistence code */ public int code() { return code; }
|
||||
/** Resolves a stable persistence code. */
|
||||
public static ObjectType fromCode(int code) {
|
||||
for (ObjectType candidate : values()) if (candidate.code == code) return candidate;
|
||||
throw new IllegalArgumentException("Unknown disclosure object-type code");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration-driven safe defaults.
|
||||
*
|
||||
* @param rootCa root certificate default
|
||||
* @param intermediateCa intermediate certificate default
|
||||
* @param caChain chain default
|
||||
* @param crl CRL default
|
||||
* @param leaf leaf default
|
||||
* @param sensitiveLeaf sensitive leaf default
|
||||
*/
|
||||
public record Defaults(Policy rootCa, Policy intermediateCa, Policy caChain, Policy crl,
|
||||
Policy leaf, Policy sensitiveLeaf) {
|
||||
/** Validates non-null defaults and prohibits public sensitive-leaf defaults. */
|
||||
public Defaults {
|
||||
Objects.requireNonNull(rootCa, "rootCa");
|
||||
Objects.requireNonNull(intermediateCa, "intermediateCa");
|
||||
Objects.requireNonNull(caChain, "caChain");
|
||||
Objects.requireNonNull(crl, "crl");
|
||||
Objects.requireNonNull(leaf, "leaf");
|
||||
Objects.requireNonNull(sensitiveLeaf, "sensitiveLeaf");
|
||||
if (leaf == Policy.PUBLIC || sensitiveLeaf == Policy.PUBLIC
|
||||
|| sensitiveLeaf == Policy.PUBLIC_UNLISTED) {
|
||||
throw new IllegalArgumentException("Leaf defaults must not silently expose certificate data");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return normative recommended defaults */
|
||||
public static Defaults recommended() {
|
||||
return new Defaults(Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC,
|
||||
Policy.OWNER_ONLY, Policy.RESTRICTED);
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable policy bound to exact object and profile/policy commitment. */
|
||||
public record Record(PkiId objectId, ObjectType objectType, Policy policy, Optional<String> ownerPrincipalId,
|
||||
String policyCommitment, Instant updatedAt) {
|
||||
/** Validates the durable disclosure record. */
|
||||
public Record {
|
||||
Objects.requireNonNull(objectId, "objectId");
|
||||
Objects.requireNonNull(objectType, "objectType");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
|
||||
.map(Permission::requirePrincipal);
|
||||
if (policyCommitment == null || !policyCommitment.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Disclosure policy commitment is invalid");
|
||||
}
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (objectType == ObjectType.LEAF_CERTIFICATE && policy == Policy.OWNER_ONLY
|
||||
&& ownerPrincipalId.isEmpty()) {
|
||||
throw new IllegalArgumentException("Owner-only leaf disclosure requires an owner");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persisted commitment-only capability authority. */
|
||||
public record Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
|
||||
byte[] tokenCommitment, Instant expiresAt, 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");
|
||||
tokenCommitment = Objects.requireNonNull(tokenCommitment, "tokenCommitment").clone();
|
||||
if (tokenCommitment.length != 32) throw new IllegalArgumentException("Capability commitment length");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
}
|
||||
|
||||
/** Returns a defensive commitment copy. */
|
||||
@Override public byte[] tokenCommitment() { return tokenCommitment.clone(); }
|
||||
}
|
||||
|
||||
/** One-time raw-token issuance result. */
|
||||
public record IssuedCapability(Capability capability, byte[] token) {
|
||||
/** Validates and snapshots the one-time token. */
|
||||
public IssuedCapability {
|
||||
Objects.requireNonNull(capability, "capability");
|
||||
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(); }
|
||||
}
|
||||
|
||||
/** Safe retrieval result that reveals no protected-object existence. */
|
||||
public enum Decision { ALLOWED, DENIED }
|
||||
|
||||
private final RealmId realmId;
|
||||
private final ServerControlStore store;
|
||||
private final Defaults defaults;
|
||||
private final Clock clock;
|
||||
private final SecureRandom random;
|
||||
private final SafeAudit audit;
|
||||
|
||||
/** Creates a durable disclosure service with an injected random source and clock. */
|
||||
public DisclosureService(RealmId realmId, ServerControlStore store, Defaults defaults, Clock clock,
|
||||
SecureRandom random, zeroecho.pki.spi.audit.AuditSink auditSink) {
|
||||
this.realmId = Objects.requireNonNull(realmId, "realmId");
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.defaults = Objects.requireNonNull(defaults, "defaults");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.random = Objects.requireNonNull(random, "random");
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
}
|
||||
|
||||
/** Returns the default policy for one object classification. */
|
||||
public Policy defaultPolicy(ObjectType type, boolean rootCa, boolean sensitive) {
|
||||
Objects.requireNonNull(type, "type");
|
||||
return switch (type) {
|
||||
case CA_CERTIFICATE -> rootCa ? defaults.rootCa() : defaults.intermediateCa();
|
||||
case CA_CHAIN -> defaults.caChain();
|
||||
case CRL -> defaults.crl();
|
||||
case LEAF_CERTIFICATE -> sensitive ? defaults.sensitiveLeaf() : defaults.leaf();
|
||||
case STATUS_OBJECT -> Policy.RESTRICTED;
|
||||
};
|
||||
}
|
||||
|
||||
/** Persists the first disclosure record without touching PKI content. */
|
||||
public synchronized Record register(PkiId objectId, ObjectType type, Policy policy,
|
||||
Optional<String> ownerPrincipalId,
|
||||
String profileOrPolicyCommitment, String actorPrincipalId) {
|
||||
ownerPrincipalId.ifPresent(store::requirePrincipal);
|
||||
Record record = new Record(objectId, type, policy, ownerPrincipalId,
|
||||
requireDigest(profileOrPolicyCommitment), clock.instant());
|
||||
store.createDisclosure(record);
|
||||
audit.record("DISCLOSURE_REGISTER", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Changes only disclosure metadata; increased exposure may require approval. */
|
||||
public synchronized Record change(PkiId objectId, Policy policy, boolean increasedExposureApprovalRequired,
|
||||
boolean approvalPresented, String actorPrincipalId) {
|
||||
Record current = store.requireDisclosure(objectId);
|
||||
if (increasedExposureApprovalRequired && exposure(policy) < exposure(current.policy())
|
||||
&& !approvalPresented) {
|
||||
throw new IllegalStateException("Increased disclosure requires approval");
|
||||
}
|
||||
Record updated = new Record(current.objectId(), current.objectType(), policy, current.ownerPrincipalId(),
|
||||
current.policyCommitment(), clock.instant());
|
||||
store.replaceDisclosure(current, updated);
|
||||
audit.record("DISCLOSURE_CHANGE", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Evaluates direct retrieval separately from search authorization. */
|
||||
public synchronized Decision decide(PkiId objectId, Optional<SecurityPrincipal> principal,
|
||||
boolean ownerRelationship,
|
||||
boolean explicitAdministrativePermission, Optional<byte[]> capabilityToken) {
|
||||
Record record = store.requireDisclosure(objectId);
|
||||
return switch (record.policy()) {
|
||||
case PUBLIC -> Decision.ALLOWED;
|
||||
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token))
|
||||
.isPresent() ? Decision.ALLOWED : Decision.DENIED;
|
||||
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
|
||||
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
|
||||
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
|
||||
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
|
||||
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship
|
||||
? Decision.ALLOWED : Decision.DENIED;
|
||||
};
|
||||
}
|
||||
|
||||
/** Issues 256 random capability bits and persists only their commitment. */
|
||||
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
|
||||
String 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");
|
||||
}
|
||||
byte[] token = new byte[32];
|
||||
byte[] identity = new byte[16];
|
||||
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);
|
||||
store.createCapability(capability);
|
||||
audit.record("CAPABILITY_ISSUE", actorPrincipalId, Optional.of(objectId), Map.of("issued", "true"));
|
||||
return new IssuedCapability(capability, token);
|
||||
}
|
||||
|
||||
/** Revokes a capability without revealing or recovering its raw token. */
|
||||
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);
|
||||
store.replaceCapability(current, revoked);
|
||||
audit.record("CAPABILITY_REVOKE", actorPrincipalId, Optional.of(current.objectId()), Map.of("revoked", "true"));
|
||||
return revoked;
|
||||
}
|
||||
|
||||
private boolean validCapability(PkiId objectId, byte[] token) {
|
||||
if (token == null || token.length != 32) return false;
|
||||
for (Capability capability : store.capabilitiesFor(objectId)) {
|
||||
if (!capability.revoked() && capability.realmId().equals(realmId)
|
||||
&& "RETRIEVE".equals(capability.action())
|
||||
&& clock.instant().isBefore(capability.expiresAt())
|
||||
&& MessageDigest.isEqual(capability.tokenCommitment(),
|
||||
capabilityCommitment(objectId, capability.expiresAt(), token))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update(realmId.value().getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(objectId.value().getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(expiry.toEpochMilli()).array());
|
||||
digest.update(token);
|
||||
return digest.digest();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static int exposure(Policy policy) {
|
||||
return switch (policy) {
|
||||
case PUBLIC -> 0; case PUBLIC_UNLISTED -> 1; case AUTHENTICATED -> 2;
|
||||
case OWNER_ONLY -> 3; case RESTRICTED -> 4; case NOT_PUBLISHED -> 5;
|
||||
};
|
||||
}
|
||||
|
||||
private static String requireDigest(String value) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("Policy commitment is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*******************************************************************************
|
||||
* 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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.pki.application.PkiOperation;
|
||||
|
||||
/** Immutable security descriptors keyed by existing typed-operation identities. */
|
||||
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
|
||||
public final class OperationSecurityDescriptors {
|
||||
/** Approval treatment for one operation. */
|
||||
public enum ApprovalCategory { NONE, HIGH_RISK }
|
||||
|
||||
/** Future transport eligibility without defining a transport protocol. */
|
||||
public enum Eligibility { ADMINISTRATIVE, PUBLIC_READ }
|
||||
|
||||
/**
|
||||
* Immutable operation security descriptor.
|
||||
*
|
||||
* @param operationId stable typed-operation identity
|
||||
* @param action required permission
|
||||
* @param resourceType required resource type
|
||||
* @param mutating whether the operation may commit state
|
||||
* @param dataView required data view
|
||||
* @param approvalCategory approval treatment
|
||||
* @param eligibility future transport eligibility metadata
|
||||
*/
|
||||
public record Descriptor(String operationId, 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(action, "action");
|
||||
Objects.requireNonNull(resourceType, "resourceType");
|
||||
Objects.requireNonNull(dataView, "dataView");
|
||||
Objects.requireNonNull(approvalCategory, "approvalCategory");
|
||||
Objects.requireNonNull(eligibility, "eligibility");
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Descriptor> descriptors;
|
||||
|
||||
/** Creates the closed current gateway descriptor registry. */
|
||||
public OperationSecurityDescriptors() {
|
||||
this(List.of(
|
||||
read(PkiOperation.ListAuthorities.NAME, Permission.Action.AUTHORITY_LIST, Permission.ResourceType.AUTHORITY),
|
||||
read(PkiOperation.InspectAuthority.NAME, Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY),
|
||||
mutate(PkiOperation.CreateAuthority.NAME, Permission.Action.AUTHORITY_CREATE, Permission.ResourceType.AUTHORITY, true),
|
||||
mutate(PkiOperation.TransitionAuthority.NAME, Permission.Action.AUTHORITY_ACTIVATE, Permission.ResourceType.AUTHORITY, true),
|
||||
read(PkiOperation.ListProfileVersions.NAME, Permission.Action.PROFILE_READ, Permission.ResourceType.PROFILE),
|
||||
read(PkiOperation.InspectProfile.NAME, Permission.Action.PROFILE_READ, Permission.ResourceType.PROFILE),
|
||||
mutate(PkiOperation.ActivateProfile.NAME, Permission.Action.PROFILE_ACTIVATE, Permission.ResourceType.PROFILE, true),
|
||||
read(PkiOperation.InspectCredential.NAME, Permission.Action.CERTIFICATE_READ_METADATA, Permission.ResourceType.CERTIFICATE),
|
||||
mutate(PkiOperation.IssueCredential.NAME, Permission.Action.CERTIFICATE_ISSUE, Permission.ResourceType.CERTIFICATE, false),
|
||||
mutate(PkiOperation.RevokeCredential.NAME, Permission.Action.CERTIFICATE_REVOKE, Permission.ResourceType.REVOCATION, false),
|
||||
read(PkiOperation.ReadRevocationHistory.NAME, Permission.Action.REVOCATION_HISTORY_READ, Permission.ResourceType.REVOCATION),
|
||||
mutate(PkiOperation.GenerateStatus.NAME, Permission.Action.CRL_GENERATE, Permission.ResourceType.STATUS_OBJECT, false),
|
||||
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)));
|
||||
}
|
||||
|
||||
/** Creates a registry and rejects duplicate operation identities. */
|
||||
public OperationSecurityDescriptors(List<Descriptor> values) {
|
||||
Map<String, Descriptor> copy = new LinkedHashMap<>();
|
||||
for (Descriptor descriptor : values) {
|
||||
if (copy.putIfAbsent(descriptor.operationId(), descriptor) != null) {
|
||||
throw new IllegalArgumentException("Duplicate operation security descriptor");
|
||||
}
|
||||
}
|
||||
descriptors = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
/** Resolves one descriptor or fails closed. */
|
||||
public Descriptor require(PkiOperation 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");
|
||||
validateOperationType(operation, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/** @return immutable descriptor map keyed by operation ID */
|
||||
public Map<String, Descriptor> descriptors() {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
/** Computes an exact deterministic safe operation commitment. */
|
||||
public String commitment(RealmId realmId, PkiOperation operation, Permission.Resource resource) {
|
||||
Descriptor descriptor = require(operation);
|
||||
return ApprovalService.digest(realmId.value() + '|' + descriptor.operationId() + '|'
|
||||
+ canonicalArguments(operation) + '|' + canonicalResource(resource));
|
||||
}
|
||||
|
||||
/** Verifies that caller-supplied security scope matches operation identities. */
|
||||
public void validateResource(PkiOperation operation, Permission.Resource resource) {
|
||||
Descriptor descriptor = require(operation);
|
||||
if (descriptor.resourceType() != resource.type()) {
|
||||
throw new SecurityException("Operation resource type is invalid");
|
||||
}
|
||||
boolean valid = switch (operation) {
|
||||
case PkiOperation.InspectAuthority value -> matchesAuthorityAndObject(resource, value.caId());
|
||||
case PkiOperation.TransitionAuthority value -> matchesAuthorityAndObject(resource, value.caId());
|
||||
case PkiOperation.CreateAuthority ignored -> resource.scope().authorityId().isEmpty()
|
||||
&& resource.objectId().isEmpty();
|
||||
case PkiOperation.ListAuthorities ignored -> resource.scope().authorityId().isEmpty();
|
||||
case PkiOperation.InspectProfile value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
|
||||
case PkiOperation.ListProfileVersions value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
|
||||
case PkiOperation.ActivateProfile value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
|
||||
case PkiOperation.InspectCredential value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
|
||||
&& resource.scope().authorityId().isPresent();
|
||||
case PkiOperation.IssueCredential value -> resource.scope().authorityId().filter(value.issuerCaId()::equals).isPresent()
|
||||
&& resource.scope().profileId().filter(value.profileId()::equals).isPresent();
|
||||
case PkiOperation.RevokeCredential value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
|
||||
&& resource.scope().authorityId().isPresent();
|
||||
case PkiOperation.ReadRevocationHistory value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
|
||||
&& resource.scope().authorityId().isPresent();
|
||||
case PkiOperation.GenerateStatus value -> resource.scope().authorityId().filter(value.issuerCaId()::equals).isPresent();
|
||||
case PkiOperation.InspectPublication value -> resource.objectId().filter(value.publicationId()::equals).isPresent()
|
||||
&& resource.scope().authorityId().isPresent();
|
||||
case PkiOperation.ProcessPublication value -> resource.objectId().filter(value.publicationId()::equals).isPresent()
|
||||
&& resource.scope().authorityId().isPresent();
|
||||
case PkiOperation.ListAlgorithmBindings ignored -> resource.scope().authorityId().isEmpty();
|
||||
case PkiOperation.InspectAlgorithmBinding ignored -> resource.scope().authorityId().isEmpty();
|
||||
default -> false;
|
||||
};
|
||||
if (!valid) throw new SecurityException("Operation security scope does not match typed arguments");
|
||||
}
|
||||
|
||||
private static boolean matchesAuthorityAndObject(Permission.Resource resource, zeroecho.pki.api.PkiId id) {
|
||||
return resource.scope().authorityId().filter(id::equals).isPresent()
|
||||
&& resource.objectId().filter(id::equals).isPresent();
|
||||
}
|
||||
|
||||
private static String canonicalArguments(PkiOperation operation) {
|
||||
return switch (operation) {
|
||||
case PkiOperation.ListAuthorities value -> "limit=" + value.limit();
|
||||
case PkiOperation.InspectAuthority value -> "ca=" + atom(value.caId().value());
|
||||
case PkiOperation.CreateAuthority value -> "format=" + atom(value.formatId().value()) + ";subject="
|
||||
+ atom(value.subjectRef().value()) + ";profile=" + atom(value.profileId())
|
||||
+ ";keyRef=" + atom(value.keyRef().value());
|
||||
case PkiOperation.TransitionAuthority value -> "ca=" + atom(value.caId().value()) + ";state="
|
||||
+ value.state().name() + ";reason=" + atom(value.reason());
|
||||
case PkiOperation.ListProfileVersions value -> "profile=" + atom(value.profileId());
|
||||
case PkiOperation.InspectProfile value -> "profile=" + atom(value.profileId()) + ";version=" + value.profileVersion();
|
||||
case PkiOperation.ActivateProfile value -> "profile=" + atom(value.profileId()) + ";version=" + value.profileVersion();
|
||||
case PkiOperation.InspectCredential value -> "credential=" + atom(value.credentialId().value());
|
||||
case PkiOperation.IssueCredential value -> "issuer=" + atom(value.issuerCaId().value()) + ";request="
|
||||
+ atom(value.requestId().value()) + ";profile=" + atom(value.profileId());
|
||||
case PkiOperation.RevokeCredential value -> "credential=" + atom(value.credentialId().value())
|
||||
+ ";reason=" + value.reason().name();
|
||||
case PkiOperation.ReadRevocationHistory value -> "credential=" + atom(value.credentialId().value())
|
||||
+ ";limit=" + value.limit();
|
||||
case PkiOperation.GenerateStatus value -> "issuer=" + atom(value.issuerCaId().value()) + ";type="
|
||||
+ value.type().name() + ";format=" + atom(value.formatId().value());
|
||||
case PkiOperation.InspectPublication value -> "publication=" + atom(value.publicationId().value());
|
||||
case PkiOperation.ProcessPublication value -> "publication=" + atom(value.publicationId().value());
|
||||
case PkiOperation.ListAlgorithmBindings value -> "origin=" + atom(value.origin().orElse("")) + ";role="
|
||||
+ atom(value.role().orElse("")) + ";limit=" + value.limit();
|
||||
case PkiOperation.InspectAlgorithmBinding value -> "binding=" + atom(value.bindingId());
|
||||
default -> throw new SecurityException("Operation has no canonical security encoding");
|
||||
};
|
||||
}
|
||||
|
||||
private static String canonicalResource(Permission.Resource resource) {
|
||||
Permission.Scope scope = resource.scope();
|
||||
return resource.type().name() + '|' + atom(scope.realmId().value()) + '|'
|
||||
+ atom(scope.authorityId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
|
||||
+ atom(scope.issuerId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
|
||||
+ atom(scope.profileId().orElse("")) + '|'
|
||||
+ atom(resource.objectId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
|
||||
+ atom(resource.ownerPrincipalId().orElse(""));
|
||||
}
|
||||
|
||||
private static String atom(String value) {
|
||||
return ApprovalService.digest(value);
|
||||
}
|
||||
|
||||
private static Descriptor read(String id, Permission.Action action, Permission.ResourceType type) {
|
||||
return new Descriptor(id, 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,
|
||||
highRisk ? ApprovalCategory.HIGH_RISK : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
|
||||
}
|
||||
|
||||
private static void validateOperationType(PkiOperation operation, Descriptor descriptor) {
|
||||
if (!operation.name().equals(descriptor.operationId())) {
|
||||
throw new SecurityException("Operation identity and descriptor differ");
|
||||
}
|
||||
}
|
||||
}
|
||||
281
pki-server/src/main/java/zeroecho/pki/server/Permission.java
Normal file
281
pki-server/src/main/java/zeroecho/pki/server/Permission.java
Normal file
@@ -0,0 +1,281 @@
|
||||
/*******************************************************************************
|
||||
* 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.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
|
||||
/** Closed permission vocabulary and immutable scoped grant model. */
|
||||
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.ControlStatementBraces",
|
||||
"PMD.AvoidLiteralsInIfCondition", "PMD.CommentDefaultAccessModifier" })
|
||||
public final class Permission {
|
||||
private Permission() {
|
||||
}
|
||||
|
||||
/** Stable permission actions; semantic separation is part of the public contract. */
|
||||
public enum Action {
|
||||
REALM_READ(1), SERVER_HEALTH_READ(2), SERVER_CONFIGURATION_READ(3),
|
||||
SERVER_CONFIGURATION_UPDATE(4), IDENTITY_PROVIDER_MANAGE(5), PRINCIPAL_MANAGE(6),
|
||||
ROLE_MANAGE(7), PERMISSION_GRANT(8), AUTHORITY_LIST(20), AUTHORITY_READ(21),
|
||||
AUTHORITY_CREATE(22), AUTHORITY_IMPORT(23), AUTHORITY_ACTIVATE(24), AUTHORITY_SUSPEND(25),
|
||||
AUTHORITY_RETIRE(26), ISSUER_CREATE(27), ISSUER_ROTATE(28), ISSUER_RETIRE(29),
|
||||
CA_CHAIN_DOWNLOAD(30), PROFILE_READ(40), PROFILE_REGISTER(41), PROFILE_VALIDATE(42),
|
||||
PROFILE_ACTIVATE(43), PROFILE_DEACTIVATE(44), POLICY_READ(45), POLICY_UPDATE(46),
|
||||
X509_BINDING_READ(47), X509_BINDING_PROVIDER_ENABLE(48), REQUEST_SUBMIT(60),
|
||||
REQUEST_READ_OWN(61), REQUEST_READ_ANY(62), REQUEST_APPROVE(63), REQUEST_REJECT(64),
|
||||
REQUEST_CANCEL(65), CERTIFICATE_ISSUE(70), CERTIFICATE_RENEW(71), CERTIFICATE_REKEY(72),
|
||||
CERTIFICATE_READ_METADATA(73), CERTIFICATE_READ_CONTENT(74), CERTIFICATE_SEARCH(75),
|
||||
CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78),
|
||||
CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
|
||||
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
|
||||
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);
|
||||
|
||||
private final int code;
|
||||
|
||||
Action(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/** @return stable persistence code */
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Resolves a stable persistence code. */
|
||||
public static Action fromCode(int code) {
|
||||
for (Action candidate : values()) {
|
||||
if (candidate.code == code) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown permission action code");
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant effect. */
|
||||
public enum Effect {
|
||||
ALLOW(1), DENY(2);
|
||||
private final int code;
|
||||
Effect(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
/** Resolves a stable code. */
|
||||
public static Effect fromCode(int code) {
|
||||
return switch (code) { case 1 -> ALLOW; case 2 -> DENY;
|
||||
default -> throw new IllegalArgumentException("Unknown grant effect code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** Security resource types, distinct from PKI authority ownership. */
|
||||
public enum ResourceType {
|
||||
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);
|
||||
private final int code;
|
||||
ResourceType(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
/** Resolves a stable code. */
|
||||
public static ResourceType fromCode(int code) {
|
||||
for (ResourceType candidate : values()) if (candidate.code == code) return candidate;
|
||||
throw new IllegalArgumentException("Unknown resource type code");
|
||||
}
|
||||
}
|
||||
|
||||
/** Relationship between the requesting principal and the target object. */
|
||||
public enum Relationship {
|
||||
OWN(1), ANY(2);
|
||||
private final int code;
|
||||
Relationship(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
/** Resolves a stable code. */
|
||||
public static Relationship fromCode(int code) {
|
||||
return switch (code) { case 1 -> OWN; case 2 -> ANY;
|
||||
default -> throw new IllegalArgumentException("Unknown relationship code"); };
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicitly separated disclosure and audit data views. */
|
||||
public enum DataView {
|
||||
METADATA_REDACTED(1), METADATA_FULL(2), CONTENT_FULL(3), PII_FULL(4);
|
||||
private final int code;
|
||||
DataView(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
/** Resolves a stable code. */
|
||||
public static DataView fromCode(int code) {
|
||||
return switch (code) { case 1 -> METADATA_REDACTED; case 2 -> METADATA_FULL;
|
||||
case 3 -> CONTENT_FULL; case 4 -> PII_FULL;
|
||||
default -> throw new IllegalArgumentException("Unknown data-view code"); };
|
||||
}
|
||||
|
||||
/** Tests view sufficiency without merging content and PII authority. */
|
||||
public boolean permits(DataView requested) {
|
||||
Objects.requireNonNull(requested, "requested");
|
||||
return this == requested || this == METADATA_FULL && requested == METADATA_REDACTED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed non-programmable grant conditions. */
|
||||
public enum Condition {
|
||||
REASON_REQUIRED(1), APPROVAL_REQUIRED(2), REQUESTER_APPROVER_SEPARATION(3),
|
||||
AUTHORITY_ACTIVE(4), PROFILE_MATCH_REQUIRED(5);
|
||||
private final int code;
|
||||
Condition(int code) { this.code = code; }
|
||||
/** @return stable code */ public int code() { return code; }
|
||||
/** Resolves a stable code. */
|
||||
public static Condition fromCode(int code) {
|
||||
for (Condition candidate : values()) if (candidate.code == code) return candidate;
|
||||
throw new IllegalArgumentException("Unknown condition code");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical resource scope supplied independently of object contents.
|
||||
*
|
||||
* @param realmId realm identity
|
||||
* @param authorityId explicit logical authority when applicable
|
||||
* @param issuerId explicit issuer generation when applicable
|
||||
* @param profileId explicit profile when applicable
|
||||
*/
|
||||
public record Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
|
||||
Optional<String> profileId) {
|
||||
/** Validates the exact finite scope. */
|
||||
public Scope {
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
authorityId = Objects.requireNonNull(authorityId, "authorityId");
|
||||
issuerId = Objects.requireNonNull(issuerId, "issuerId");
|
||||
profileId = Objects.requireNonNull(profileId, "profileId").map(Permission::requireProfile);
|
||||
if (issuerId.isPresent() && authorityId.isEmpty()) {
|
||||
throw new IllegalArgumentException("Issuer scope requires authority scope");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral target reference. Possession is never authorization.
|
||||
*
|
||||
* @param type resource type
|
||||
* @param scope exact security scope
|
||||
* @param objectId optional canonical object identity
|
||||
* @param ownerPrincipalId optional owning principal identity
|
||||
*/
|
||||
public record Resource(ResourceType type, Scope scope, Optional<PkiId> objectId,
|
||||
Optional<String> ownerPrincipalId) {
|
||||
/** Validates the finite resource reference. */
|
||||
public Resource {
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
objectId = Objects.requireNonNull(objectId, "objectId");
|
||||
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
|
||||
.map(Permission::requirePrincipal);
|
||||
}
|
||||
|
||||
/** @return whether the principal owns this object */
|
||||
public boolean ownedBy(SecurityPrincipal principal) {
|
||||
Objects.requireNonNull(principal, "principal");
|
||||
return ownerPrincipalId.filter(principal.principalId()::equals).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable persisted permission grant.
|
||||
*
|
||||
* @param grantId stable grant identity
|
||||
* @param principalId grantee identity
|
||||
* @param effect allow or deny
|
||||
* @param action exact action
|
||||
* @param resourceType exact resource type
|
||||
* @param scope exact non-wildcard realm and optional subscopes
|
||||
* @param relationship own or any
|
||||
* @param dataView maximum exact view
|
||||
* @param conditions closed conditions
|
||||
* @param expiresAt optional mandatory upper bound for temporary grants
|
||||
* @param enabled whether the grant participates in decisions
|
||||
*/
|
||||
public record Grant(String grantId, String principalId, Effect effect, Action action,
|
||||
ResourceType resourceType, Scope scope, Relationship relationship, DataView dataView,
|
||||
Set<Condition> conditions, Optional<Instant> expiresAt, boolean enabled) {
|
||||
/** Validates and snapshots the finite grant. */
|
||||
public Grant {
|
||||
requireId(grantId, "grant");
|
||||
requirePrincipal(principalId);
|
||||
Objects.requireNonNull(effect, "effect");
|
||||
Objects.requireNonNull(action, "action");
|
||||
Objects.requireNonNull(resourceType, "resourceType");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
Objects.requireNonNull(relationship, "relationship");
|
||||
Objects.requireNonNull(dataView, "dataView");
|
||||
conditions = Set.copyOf(Objects.requireNonNull(conditions, "conditions"));
|
||||
expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
}
|
||||
}
|
||||
|
||||
/** Safe finite context used only by closed conditions. */
|
||||
public record Context(Optional<String> reason, boolean approvalPresent, boolean authorityActive,
|
||||
Map<String, String> safeAttributes) {
|
||||
/** Validates the finite condition context. */
|
||||
public Context {
|
||||
reason = Objects.requireNonNull(reason, "reason").map(value -> requireBounded(value, 1024, "reason"));
|
||||
safeAttributes = Map.copyOf(Objects.requireNonNull(safeAttributes, "safeAttributes"));
|
||||
if (safeAttributes.size() > 32) throw new IllegalArgumentException("Too many context attributes");
|
||||
}
|
||||
|
||||
/** @return empty safe context */
|
||||
public static Context empty() {
|
||||
return new Context(Optional.empty(), false, false, Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
static void requireId(String value, String label) {
|
||||
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
|
||||
throw new IllegalArgumentException(label + " identity is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
static String requirePrincipal(String value) { requireId(value, "principal"); return value; }
|
||||
static String requireProfile(String value) { return requireBounded(value, 256, "profile"); }
|
||||
static String requireBounded(String value, int limit, String label) {
|
||||
if (value == null || value.isBlank() || value.length() > limit) {
|
||||
throw new IllegalArgumentException(label + " is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
60
pki-server/src/main/java/zeroecho/pki/server/RealmId.java
Normal file
60
pki-server/src/main/java/zeroecho/pki/server/RealmId.java
Normal file
@@ -0,0 +1,60 @@
|
||||
/*******************************************************************************
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Canonical identity of one PKI realm.
|
||||
*
|
||||
* <p>The identity is deployment-defined and has no filesystem, listener, Java
|
||||
* type, or store-name interpretation.</p>
|
||||
*
|
||||
* @param value canonical realm identifier
|
||||
*/
|
||||
public record RealmId(String value) {
|
||||
/** Validates the canonical realm identifier. */
|
||||
public RealmId {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (!value.matches("[a-z0-9][a-z0-9._-]{0,127}")) {
|
||||
throw new IllegalArgumentException("Realm identity is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the canonical identifier. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*******************************************************************************
|
||||
* 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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.core.TokenStreamFactory;
|
||||
import tools.jackson.core.json.JsonFactory;
|
||||
|
||||
/** Strict deterministic loader for versioned built-in role templates. */
|
||||
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ControlStatementBraces",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.ExceptionAsFlowControl",
|
||||
"PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace", "PMD.UseEnumCollections" })
|
||||
public final class RoleTemplateCatalog {
|
||||
/** Classpath resource holding the normative role templates. */
|
||||
public static final String RESOURCE = "zeroecho/pki/server/security/role-templates-v1.json";
|
||||
private static final int MAXIMUM_RESOURCE_BYTES = 131_072;
|
||||
private static final TokenStreamFactory JSON = JsonFactory.builder().build();
|
||||
|
||||
/** Immutable role template that has no authority until scoped assignment. */
|
||||
public record Template(String templateId, int version, Set<Permission.Action> actions) {
|
||||
/** Validates the stable template. */
|
||||
public Template {
|
||||
Permission.requireId(templateId, "role template");
|
||||
if (version <= 0) throw new IllegalArgumentException("Role template version must be positive");
|
||||
actions = Set.copyOf(Objects.requireNonNull(actions, "actions"));
|
||||
if (actions.isEmpty()) throw new IllegalArgumentException("Role template actions must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
/** Validates the exact scoped assignment. */
|
||||
public Assignment {
|
||||
Permission.requireId(assignmentId, "assignment");
|
||||
Permission.requirePrincipal(principalId);
|
||||
Permission.requireId(templateId, "role template");
|
||||
if (templateVersion <= 0) throw new IllegalArgumentException("Template version must be positive");
|
||||
Objects.requireNonNull(scope, "scope");
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Template> templates;
|
||||
|
||||
private RoleTemplateCatalog(List<Template> templates) {
|
||||
this.templates = List.copyOf(templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the built-in strict resource through the supplied class loader.
|
||||
*
|
||||
* @param loader class loader owning server resources
|
||||
* @return immutable deterministic catalog
|
||||
*/
|
||||
public static RoleTemplateCatalog load(ClassLoader loader) {
|
||||
Objects.requireNonNull(loader, "loader");
|
||||
try (InputStream input = Objects.requireNonNull(loader.getResourceAsStream(RESOURCE),
|
||||
"Built-in role-template resource is missing")) {
|
||||
byte[] encoded = input.readNBytes(MAXIMUM_RESOURCE_BYTES + 1);
|
||||
if (encoded.length > MAXIMUM_RESOURCE_BYTES) {
|
||||
throw new IllegalArgumentException("Role-template resource exceeds its technical bound");
|
||||
}
|
||||
return parse(encoded);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Role-template resource cannot be read", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one complete strict versioned resource.
|
||||
*
|
||||
* @param encoded bounded UTF-8 JSON document
|
||||
* @return immutable deterministic catalog
|
||||
*/
|
||||
public static RoleTemplateCatalog parse(byte[] encoded) {
|
||||
Objects.requireNonNull(encoded, "encoded");
|
||||
if (encoded.length == 0 || encoded.length > MAXIMUM_RESOURCE_BYTES) {
|
||||
throw new IllegalArgumentException("Role-template resource size is invalid");
|
||||
}
|
||||
try (JsonParser parser = JSON.createParser(encoded)) {
|
||||
require(parser.nextToken(), JsonToken.START_OBJECT);
|
||||
Integer schema = null;
|
||||
List<Template> result = null;
|
||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
require(parser.currentToken(), JsonToken.PROPERTY_NAME);
|
||||
String field = parser.currentName();
|
||||
parser.nextToken();
|
||||
if ("schemaVersion".equals(field) && schema == null) schema = parser.getIntValue();
|
||||
else if ("roles".equals(field) && result == null) result = readTemplates(parser);
|
||||
else throw new IllegalArgumentException("Unknown or duplicate role-template field");
|
||||
}
|
||||
if (parser.nextToken() != null || !Integer.valueOf(1).equals(schema) || result == null) {
|
||||
throw new IllegalArgumentException("Role-template resource framing is invalid");
|
||||
}
|
||||
result.sort(Comparator.comparing(Template::templateId));
|
||||
Set<String> ids = new HashSet<>();
|
||||
if (result.size() != 15 || result.stream().anyMatch(item -> !ids.add(item.templateId()))) {
|
||||
throw new IllegalArgumentException("Role-template set is incomplete or ambiguous");
|
||||
}
|
||||
return new RoleTemplateCatalog(result);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
if (failure instanceof IllegalArgumentException invalid) throw invalid;
|
||||
throw new IllegalArgumentException("Role-template resource is malformed");
|
||||
}
|
||||
}
|
||||
|
||||
/** @return immutable templates ordered by stable ID */
|
||||
public List<Template> templates() {
|
||||
return templates;
|
||||
}
|
||||
|
||||
/** Resolves one exact template. */
|
||||
public Template require(String templateId, int version) {
|
||||
return templates.stream().filter(item -> item.templateId().equals(templateId) && item.version() == version)
|
||||
.findFirst().orElseThrow(() -> new IllegalArgumentException("Role template is unavailable"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one enabled scoped assignment to explicit immutable grants.
|
||||
*
|
||||
* @param assignment persisted assignment
|
||||
* @return deterministic finite grants, or an empty list when disabled
|
||||
*/
|
||||
public List<Permission.Grant> instantiate(Assignment assignment) {
|
||||
Assignment exact = Objects.requireNonNull(assignment, "assignment");
|
||||
if (!exact.enabled()) return List.of();
|
||||
Template template = require(exact.templateId(), exact.templateVersion());
|
||||
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))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static Permission.ResourceType resourceType(Permission.Action action) {
|
||||
String name = action.name();
|
||||
if (name.startsWith("AUTHORITY")) return Permission.ResourceType.AUTHORITY;
|
||||
if (name.startsWith("ISSUER") || name.startsWith("CA_CHAIN")) return Permission.ResourceType.ISSUER;
|
||||
if (name.startsWith("PROFILE")) return Permission.ResourceType.PROFILE;
|
||||
if (name.startsWith("POLICY")) return Permission.ResourceType.POLICY;
|
||||
if (name.startsWith("X509_BINDING")) return Permission.ResourceType.X509_BINDING;
|
||||
if (name.startsWith("REQUEST")) return Permission.ResourceType.REQUEST;
|
||||
if (name.startsWith("CERTIFICATE")) return Permission.ResourceType.CERTIFICATE;
|
||||
if (name.startsWith("REVOCATION")) return Permission.ResourceType.REVOCATION;
|
||||
if (name.startsWith("CRL") || name.startsWith("OCSP")) return Permission.ResourceType.STATUS_OBJECT;
|
||||
if (name.startsWith("PUBLICATION")) return Permission.ResourceType.PUBLICATION;
|
||||
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("PRINCIPAL")) return Permission.ResourceType.PRINCIPAL;
|
||||
if (name.startsWith("ROLE")) return Permission.ResourceType.ROLE;
|
||||
if (name.startsWith("PERMISSION")) return Permission.ResourceType.GRANT;
|
||||
if (name.startsWith("SERVER_CONFIGURATION")) return Permission.ResourceType.SERVER_CONFIGURATION;
|
||||
return Permission.ResourceType.REALM;
|
||||
}
|
||||
|
||||
private static Permission.Relationship relationship(Template template, Permission.Action action) {
|
||||
boolean requesterCredential = "requester".equals(template.templateId())
|
||||
&& action.name().startsWith("CERTIFICATE_");
|
||||
return action.name().endsWith("_OWN") || requesterCredential
|
||||
? Permission.Relationship.OWN : Permission.Relationship.ANY;
|
||||
}
|
||||
|
||||
private static Permission.DataView dataView(Permission.Action action) {
|
||||
if (action == Permission.Action.CERTIFICATE_READ_CONTENT || action == Permission.Action.CERTIFICATE_DOWNLOAD
|
||||
|| action == Permission.Action.CA_CHAIN_DOWNLOAD || action == Permission.Action.CRL_DOWNLOAD) {
|
||||
return Permission.DataView.CONTENT_FULL;
|
||||
}
|
||||
if (action == Permission.Action.CERTIFICATE_READ_PII || action == Permission.Action.AUDIT_READ_PII) {
|
||||
return Permission.DataView.PII_FULL;
|
||||
}
|
||||
if (action == Permission.Action.AUDIT_READ_FULL) return Permission.DataView.METADATA_FULL;
|
||||
return Permission.DataView.METADATA_REDACTED;
|
||||
}
|
||||
|
||||
private static List<Template> readTemplates(JsonParser parser) throws IOException {
|
||||
require(parser.currentToken(), JsonToken.START_ARRAY);
|
||||
List<Template> result = new ArrayList<>();
|
||||
while (parser.nextToken() != JsonToken.END_ARRAY) {
|
||||
require(parser.currentToken(), JsonToken.START_OBJECT);
|
||||
String id = null;
|
||||
Integer version = null;
|
||||
Set<Permission.Action> actions = null;
|
||||
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||
require(parser.currentToken(), JsonToken.PROPERTY_NAME);
|
||||
String field = parser.currentName();
|
||||
parser.nextToken();
|
||||
if ("id".equals(field) && id == null) id = parser.getString();
|
||||
else if ("version".equals(field) && version == null) version = parser.getIntValue();
|
||||
else if ("actions".equals(field) && actions == null) actions = readActions(parser);
|
||||
else throw new IllegalArgumentException("Unknown or duplicate role field");
|
||||
}
|
||||
if (id == null || version == null || actions == null) throw new IllegalArgumentException("Role incomplete");
|
||||
result.add(new Template(id, version, actions));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<Permission.Action> readActions(JsonParser parser) throws IOException {
|
||||
require(parser.currentToken(), JsonToken.START_ARRAY);
|
||||
Set<Permission.Action> result = new HashSet<>();
|
||||
while (parser.nextToken() != JsonToken.END_ARRAY) {
|
||||
require(parser.currentToken(), JsonToken.VALUE_STRING);
|
||||
Permission.Action action = Permission.Action.valueOf(parser.getString());
|
||||
if (!result.add(action)) throw new IllegalArgumentException("Duplicate role action");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void require(JsonToken actual, JsonToken expected) {
|
||||
if (actual != expected) throw new IllegalArgumentException("Unexpected role-template token");
|
||||
}
|
||||
}
|
||||
63
pki-server/src/main/java/zeroecho/pki/server/SafeAudit.java
Normal file
63
pki-server/src/main/java/zeroecho/pki/server/SafeAudit.java
Normal file
@@ -0,0 +1,63 @@
|
||||
/*******************************************************************************
|
||||
* 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.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.audit.AuditEvent;
|
||||
import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.audit.Purpose;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
|
||||
/** Package-private safe server audit producer. */
|
||||
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
|
||||
final class SafeAudit {
|
||||
private final Clock clock;
|
||||
private final AuditSink sink;
|
||||
|
||||
SafeAudit(Clock clock, AuditSink sink) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.sink = Objects.requireNonNull(sink, "sink");
|
||||
}
|
||||
|
||||
void record(String action, String principalId, Optional<PkiId> objectId, Map<String, String> details) {
|
||||
sink.record(new AuditEvent(clock.instant(), "SERVER_SECURITY", action,
|
||||
new Principal("SERVER_PRINCIPAL", principalId), new Purpose("SERVER_CONTROL"),
|
||||
Objects.requireNonNull(objectId, "objectId"), Optional.empty(), Map.copyOf(details)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*******************************************************************************
|
||||
* 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.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Transport-independent authenticated-identity target used by authorization.
|
||||
* Authentication credentials and secrets are deliberately excluded.
|
||||
*
|
||||
* @param principalId canonical stable principal identity
|
||||
* @param type closed principal type
|
||||
* @param displayName safe administrative display label
|
||||
* @param organization optional canonical owner/organization relationship
|
||||
* @param attributes finite safe relationship attributes
|
||||
* @param enabled whether authorization assignments may be considered
|
||||
*/
|
||||
public record SecurityPrincipal(String principalId, Type type, String displayName,
|
||||
Optional<String> organization, Map<String, String> attributes, boolean enabled) {
|
||||
/** Closed principal types. */
|
||||
public enum Type {
|
||||
/** Human administrative or requesting identity. */ USER(1),
|
||||
/** Non-human authenticated service identity. */ SERVICE(2),
|
||||
/** Protocol-specific account identity. */ ACCOUNT(3),
|
||||
/** Unauthenticated public identity. */ PUBLIC(4);
|
||||
|
||||
private final int code;
|
||||
|
||||
Type(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/** @return stable persistence code */
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Resolves one stable persistence code. */
|
||||
public static Type fromCode(int code) {
|
||||
return switch (code) {
|
||||
case 1 -> USER;
|
||||
case 2 -> SERVICE;
|
||||
case 3 -> ACCOUNT;
|
||||
case 4 -> PUBLIC;
|
||||
default -> throw new IllegalArgumentException("Unknown principal type code");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates and snapshots finite safe principal metadata. */
|
||||
public SecurityPrincipal {
|
||||
requireId(principalId);
|
||||
Objects.requireNonNull(type, "type");
|
||||
if (displayName == null || displayName.isBlank() || displayName.length() > 256) {
|
||||
throw new IllegalArgumentException("Principal display name is invalid");
|
||||
}
|
||||
organization = Objects.requireNonNull(organization, "organization").map(SecurityPrincipal::requireText);
|
||||
attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes"));
|
||||
if (attributes.size() > 32 || attributes.entrySet().stream()
|
||||
.anyMatch(entry -> entry.getKey().isBlank() || entry.getKey().length() > 64
|
||||
|| entry.getValue().isBlank() || entry.getValue().length() > 256)) {
|
||||
throw new IllegalArgumentException("Principal attributes are not finite canonical metadata");
|
||||
}
|
||||
if (type == Type.PUBLIC && (!"public".equals(principalId) || organization.isPresent())) {
|
||||
throw new IllegalArgumentException("Public principal identity is fixed and unowned");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireId(String value) {
|
||||
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:@-]{0,255}")) {
|
||||
throw new IllegalArgumentException("Principal identity is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value) {
|
||||
if (value == null || value.isBlank() || value.length() > 256) {
|
||||
throw new IllegalArgumentException("Principal relationship value is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/*******************************************************************************
|
||||
* 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.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.spi.store.MetadataCommitResult;
|
||||
import zeroecho.pki.spi.store.MetadataCursor;
|
||||
import zeroecho.pki.spi.store.MetadataKey;
|
||||
import zeroecho.pki.spi.store.MetadataSnapshot;
|
||||
import zeroecho.pki.spi.store.MetadataStoreId;
|
||||
import zeroecho.pki.spi.store.MetadataTransaction;
|
||||
import zeroecho.pki.spi.store.TransactionalMetadataStore;
|
||||
|
||||
/**
|
||||
* Dedicated durable authority for finite server security and disclosure control
|
||||
* metadata.
|
||||
*
|
||||
* <p>All public operations are synchronized to serialize compare-and-replace
|
||||
* within one realm process. Cross-process exclusion and durability remain the
|
||||
* responsibility of the composed {@link TransactionalMetadataStore}. No PKI
|
||||
* certificate, CRL, CSR, key, secret, or unrestricted exception data is stored.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity",
|
||||
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
|
||||
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource",
|
||||
"PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody",
|
||||
"PMD.TooManyMethods" })
|
||||
public final class ServerControlStore implements AutoCloseable {
|
||||
/** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm";
|
||||
/** Stable namespace for principals. */ public static final String PRINCIPAL = "io.zeroecho.server.principal";
|
||||
/** Stable namespace for scoped role assignments. */ public static final String ASSIGNMENT = "io.zeroecho.server.assignment";
|
||||
/** Stable namespace for direct permission grants. */ public static final String GRANT = "io.zeroecho.server.grant";
|
||||
/** Stable namespace for approvals. */ public static final String APPROVAL = "io.zeroecho.server.approval";
|
||||
/** Stable namespace for break-glass records. */ public static final String BREAK_GLASS = "io.zeroecho.server.break-glass";
|
||||
/** Stable namespace for disclosure records. */ public static final String DISCLOSURE = "io.zeroecho.server.disclosure";
|
||||
/** 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 MAXIMUM_RECORD_BYTES = 1_048_576;
|
||||
private static final int MAXIMUM_STRING_BYTES = 16_384;
|
||||
private static final int MAXIMUM_COLLECTION = 4_096;
|
||||
private static final int KIND_REALM = 1;
|
||||
private static final int KIND_PRINCIPAL = 2;
|
||||
private static final int KIND_ASSIGNMENT = 3;
|
||||
private static final int KIND_GRANT = 4;
|
||||
private static final int KIND_APPROVAL = 5;
|
||||
private static final int KIND_BREAK_GLASS = 6;
|
||||
private static final int KIND_DISCLOSURE = 7;
|
||||
private static final int KIND_CAPABILITY = 8;
|
||||
|
||||
/**
|
||||
* Durable realm-control identity and commitments.
|
||||
*
|
||||
* @param realmId realm identity
|
||||
* @param displayName safe administrative display name
|
||||
* @param exposure authority exposure policy
|
||||
* @param authorizationCommitment authorization configuration commitment
|
||||
* @param approvalCommitment approval-policy commitment
|
||||
* @param disclosureCommitment disclosure-default commitment
|
||||
* @param controlStoreId exact metadata-store identity
|
||||
*/
|
||||
public record RealmRecord(RealmId realmId, String displayName, AuthorityExposurePolicy exposure,
|
||||
String authorizationCommitment, String approvalCommitment, String disclosureCommitment,
|
||||
MetadataStoreId controlStoreId) {
|
||||
/** Validates finite realm control metadata. */
|
||||
public RealmRecord {
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Permission.requireBounded(displayName, 256, "realm display name");
|
||||
Objects.requireNonNull(exposure, "exposure");
|
||||
requireDigest(authorizationCommitment);
|
||||
requireDigest(approvalCommitment);
|
||||
requireDigest(disclosureCommitment);
|
||||
Objects.requireNonNull(controlStoreId, "controlStoreId");
|
||||
}
|
||||
}
|
||||
|
||||
private final TransactionalMetadataStore metadata;
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
* Creates a server-control adapter over one exclusively owned metadata store.
|
||||
*
|
||||
* @param metadata lifecycle-owned metadata authority
|
||||
*/
|
||||
public ServerControlStore(TransactionalMetadataStore metadata) {
|
||||
this.metadata = Objects.requireNonNull(metadata, "metadata");
|
||||
}
|
||||
|
||||
/** @return durable server-control metadata-store identity */
|
||||
public synchronized MetadataStoreId id() {
|
||||
requireOpen();
|
||||
return metadata.id();
|
||||
}
|
||||
|
||||
/** Creates or validates the sole realm identity record. */
|
||||
public synchronized RealmRecord ensureRealm(RealmRecord expected) {
|
||||
Objects.requireNonNull(expected, "expected");
|
||||
List<RealmRecord> existing = scan(REALM, KIND_REALM, ServerControlStore::readRealm);
|
||||
if (existing.isEmpty()) {
|
||||
create(REALM, expected.realmId().value(), output -> writeRealm(output, expected));
|
||||
return expected;
|
||||
}
|
||||
if (existing.size() != 1 || !existing.getFirst().equals(expected)) {
|
||||
throw new IllegalStateException("Persisted realm control identity or commitment differs");
|
||||
}
|
||||
return existing.getFirst();
|
||||
}
|
||||
|
||||
/** Creates one principal. */
|
||||
public synchronized void createPrincipal(SecurityPrincipal value) {
|
||||
create(PRINCIPAL, value.principalId(), output -> writePrincipal(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one exact principal. */
|
||||
public synchronized void replacePrincipal(SecurityPrincipal current, SecurityPrincipal updated) {
|
||||
requireSame(current.principalId(), updated.principalId());
|
||||
replace(PRINCIPAL, current.principalId(), output -> writePrincipal(output, updated));
|
||||
}
|
||||
|
||||
/** Returns one principal or fails without revealing storage details. */
|
||||
public synchronized SecurityPrincipal requirePrincipal(String principalId) {
|
||||
return read(PRINCIPAL, principalId, KIND_PRINCIPAL, ServerControlStore::readPrincipal)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Principal is unavailable"));
|
||||
}
|
||||
|
||||
/** Creates one scoped role assignment. */
|
||||
public synchronized void createAssignment(RoleTemplateCatalog.Assignment value) {
|
||||
create(ASSIGNMENT, value.assignmentId(), output -> writeAssignment(output, value));
|
||||
}
|
||||
|
||||
/** 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()
|
||||
.filter(item -> item.principalId().equals(principalId)).toList();
|
||||
}
|
||||
|
||||
/** Creates one direct grant. */
|
||||
public synchronized void createGrant(Permission.Grant value) {
|
||||
create(GRANT, value.grantId(), output -> writeGrant(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one direct grant. */
|
||||
public synchronized void replaceGrant(Permission.Grant current, Permission.Grant updated) {
|
||||
requireSame(current.grantId(), updated.grantId());
|
||||
replace(GRANT, current.grantId(), output -> writeGrant(output, updated));
|
||||
}
|
||||
|
||||
/** Lists direct grants for one principal from one stable snapshot. */
|
||||
public synchronized List<Permission.Grant> grantsFor(String principalId) {
|
||||
return scan(GRANT, KIND_GRANT, ServerControlStore::readGrant).stream()
|
||||
.filter(item -> item.principalId().equals(principalId)).toList();
|
||||
}
|
||||
|
||||
/** Creates one approval request. */
|
||||
public synchronized void createApproval(ApprovalService.Request value) {
|
||||
create(APPROVAL, value.approvalId(), output -> writeApproval(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one approval request through compare-and-replace. */
|
||||
public synchronized void replaceApproval(ApprovalService.Request current, ApprovalService.Request updated) {
|
||||
requireSame(current.approvalId(), updated.approvalId());
|
||||
replace(APPROVAL, current.approvalId(), output -> writeApproval(output, updated));
|
||||
}
|
||||
|
||||
/** Returns one approval request. */
|
||||
public synchronized ApprovalService.Request requireApproval(String approvalId) {
|
||||
return read(APPROVAL, approvalId, KIND_APPROVAL, ServerControlStore::readApproval)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Approval is unavailable"));
|
||||
}
|
||||
|
||||
/** Creates one break-glass record. */
|
||||
public synchronized void createBreakGlass(BreakGlassService.Record value) {
|
||||
create(BREAK_GLASS, value.breakGlassId(), output -> writeBreakGlass(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one break-glass record. */
|
||||
public synchronized void replaceBreakGlass(BreakGlassService.Record current, BreakGlassService.Record updated) {
|
||||
requireSame(current.breakGlassId(), updated.breakGlassId());
|
||||
replace(BREAK_GLASS, current.breakGlassId(), output -> writeBreakGlass(output, updated));
|
||||
}
|
||||
|
||||
/** Returns one break-glass record. */
|
||||
public synchronized BreakGlassService.Record requireBreakGlass(String breakGlassId) {
|
||||
return read(BREAK_GLASS, breakGlassId, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Break-glass record is unavailable"));
|
||||
}
|
||||
|
||||
/** Lists break-glass records for one principal. */
|
||||
public synchronized List<BreakGlassService.Record> breakGlassFor(String principalId) {
|
||||
return scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass).stream()
|
||||
.filter(item -> item.principalId().equals(principalId)).toList();
|
||||
}
|
||||
|
||||
/** Creates one disclosure record. */
|
||||
public synchronized void createDisclosure(DisclosureService.Record value) {
|
||||
create(DISCLOSURE, value.objectId().value(), output -> writeDisclosure(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one disclosure record. */
|
||||
public synchronized void replaceDisclosure(DisclosureService.Record current, DisclosureService.Record updated) {
|
||||
requireSame(current.objectId().value(), updated.objectId().value());
|
||||
replace(DISCLOSURE, current.objectId().value(), output -> writeDisclosure(output, updated));
|
||||
}
|
||||
|
||||
/** Returns one disclosure record. */
|
||||
public synchronized DisclosureService.Record requireDisclosure(PkiId objectId) {
|
||||
return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
|
||||
}
|
||||
|
||||
/** Creates one commitment-only capability record. */
|
||||
public synchronized void createCapability(DisclosureService.Capability value) {
|
||||
create(CAPABILITY, value.capabilityId(), output -> writeCapability(output, value));
|
||||
}
|
||||
|
||||
/** Replaces one capability record. */
|
||||
public synchronized void replaceCapability(DisclosureService.Capability current,
|
||||
DisclosureService.Capability updated) {
|
||||
requireSame(current.capabilityId(), updated.capabilityId());
|
||||
replace(CAPABILITY, current.capabilityId(), output -> writeCapability(output, updated));
|
||||
}
|
||||
|
||||
/** Returns one capability record. */
|
||||
public synchronized DisclosureService.Capability requireCapability(String capabilityId) {
|
||||
return read(CAPABILITY, capabilityId, KIND_CAPABILITY, ServerControlStore::readCapability)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Capability is unavailable"));
|
||||
}
|
||||
|
||||
/** Lists capability commitments bound to one object. */
|
||||
public synchronized List<DisclosureService.Capability> capabilitiesFor(PkiId objectId) {
|
||||
return scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability).stream()
|
||||
.filter(item -> item.objectId().equals(objectId)).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly decodes and cross-checks every authoritative control record from
|
||||
* stable metadata snapshots before the realm is exposed.
|
||||
*/
|
||||
public synchronized void validateAll() {
|
||||
scan(REALM, KIND_REALM, ServerControlStore::readRealm);
|
||||
scan(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal);
|
||||
scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment);
|
||||
scan(GRANT, KIND_GRANT, ServerControlStore::readGrant);
|
||||
scan(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval);
|
||||
scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass);
|
||||
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
|
||||
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
|
||||
}
|
||||
|
||||
/** Validates durable relationships against the active strict role catalog. */
|
||||
public synchronized void validateReferences(RoleTemplateCatalog roles) {
|
||||
Objects.requireNonNull(roles, "roles");
|
||||
Set<String> principals = scan(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal).stream()
|
||||
.map(SecurityPrincipal::principalId).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
for (RoleTemplateCatalog.Assignment assignment
|
||||
: scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment)) {
|
||||
requirePrincipalReference(principals, assignment.principalId());
|
||||
roles.instantiate(assignment);
|
||||
}
|
||||
for (Permission.Grant grant : scan(GRANT, KIND_GRANT, ServerControlStore::readGrant)) {
|
||||
requirePrincipalReference(principals, grant.principalId());
|
||||
}
|
||||
for (BreakGlassService.Record record
|
||||
: scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass)) {
|
||||
requirePrincipalReference(principals, record.principalId());
|
||||
requirePrincipalReference(principals, record.issuerPrincipalId());
|
||||
}
|
||||
for (ApprovalService.Request request
|
||||
: scan(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval)) {
|
||||
requirePrincipalReference(principals, request.requesterPrincipalId());
|
||||
request.decisions().forEach(decision -> requirePrincipalReference(principals, decision.principalId()));
|
||||
}
|
||||
for (DisclosureService.Record record
|
||||
: scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure)) {
|
||||
record.ownerPrincipalId().ifPresent(owner -> requirePrincipalReference(principals, owner));
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens no sidecar authority and closes the sole metadata authority idempotently. */
|
||||
@Override
|
||||
public synchronized void close() throws IOException {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
metadata.close();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Optional<T> read(String namespace, String identity, int kind, Decoder<T> decoder) {
|
||||
requireOpen();
|
||||
MetadataKey key = key(namespace, identity);
|
||||
try (MetadataSnapshot snapshot = metadata.snapshot()) {
|
||||
Optional<MetadataSnapshot.Record> record = snapshot.get(key);
|
||||
if (record.isEmpty()) return Optional.empty();
|
||||
T decoded = decode(record.orElseThrow(), kind, decoder);
|
||||
requireSame(identity, identityOf(decoded));
|
||||
return Optional.of(decoded);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Server-control metadata read failed");
|
||||
}
|
||||
}
|
||||
|
||||
private <T> List<T> scan(String namespace, int kind, Decoder<T> decoder) {
|
||||
requireOpen();
|
||||
List<T> values = new ArrayList<>();
|
||||
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");
|
||||
}
|
||||
values.add(decoded);
|
||||
if (values.size() > MAXIMUM_COLLECTION) {
|
||||
throw new IllegalStateException("Server-control metadata population exceeds technical bound");
|
||||
}
|
||||
}
|
||||
return List.copyOf(values);
|
||||
} 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);
|
||||
}
|
||||
|
||||
private void replace(String namespace, String identity, Encoder encoder) {
|
||||
mutate(namespace, identity, false, encoder);
|
||||
}
|
||||
|
||||
private void mutate(String namespace, String identity, boolean create, Encoder encoder) {
|
||||
requireOpen();
|
||||
byte[] value = encode(encoder);
|
||||
MetadataKey key = key(namespace, identity);
|
||||
try (MetadataSnapshot snapshot = metadata.snapshot(); MetadataTransaction transaction = metadata.beginTransaction();
|
||||
RepeatableContent content = new ByteContent(value)) {
|
||||
Optional<MetadataSnapshot.Record> existing = snapshot.get(key);
|
||||
if (create) {
|
||||
if (existing.isPresent()) throw new IllegalStateException("Server-control record already exists");
|
||||
transaction.create(key, content, CancellationSignal.NONE);
|
||||
} else {
|
||||
MetadataSnapshot.Record record = existing
|
||||
.orElseThrow(() -> new IllegalStateException("Server-control record is unavailable"));
|
||||
transaction.replace(key, record.recordRevision(), content, CancellationSignal.NONE);
|
||||
}
|
||||
MetadataCommitResult result = transaction.commit();
|
||||
if (result.outcome() != MetadataCommitResult.Outcome.COMMITTED) {
|
||||
throw new IllegalStateException("Server-control metadata commit was not confirmed");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Server-control metadata mutation failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encode(Encoder encoder) {
|
||||
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream output = new DataOutputStream(bytes)) {
|
||||
output.writeInt(MAGIC);
|
||||
output.writeInt(SCHEMA);
|
||||
encoder.write(output);
|
||||
output.flush();
|
||||
byte[] result = bytes.toByteArray();
|
||||
if (result.length > MAXIMUM_RECORD_BYTES) throw new IllegalArgumentException("Control record too large");
|
||||
return result;
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException("In-memory control encoding failed", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T decode(RepeatableContent content, int expectedKind, Decoder<T> decoder) throws IOException {
|
||||
try (InputStream stream = content.openStream()) {
|
||||
byte[] bytes = stream.readNBytes(MAXIMUM_RECORD_BYTES + 1);
|
||||
if (bytes.length > MAXIMUM_RECORD_BYTES || stream.read() != -1) {
|
||||
throw new IllegalArgumentException("Control record exceeds its technical bound");
|
||||
}
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) {
|
||||
if (input.readInt() != MAGIC || input.readInt() != SCHEMA || input.readInt() != expectedKind) {
|
||||
throw new IllegalArgumentException("Control record framing or kind is invalid");
|
||||
}
|
||||
T result = decoder.read(input);
|
||||
if (input.read() != -1) throw new IllegalArgumentException("Trailing control record input");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataKey key(String namespace, String identity) {
|
||||
return new MetadataKey(namespace, digest(identity));
|
||||
}
|
||||
|
||||
private static String digest(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static String identityOf(Object value) {
|
||||
return switch (value) {
|
||||
case RealmRecord item -> item.realmId().value();
|
||||
case SecurityPrincipal item -> item.principalId();
|
||||
case RoleTemplateCatalog.Assignment item -> item.assignmentId();
|
||||
case Permission.Grant item -> item.grantId();
|
||||
case ApprovalService.Request item -> item.approvalId();
|
||||
case BreakGlassService.Record item -> item.breakGlassId();
|
||||
case DisclosureService.Record item -> item.objectId().value();
|
||||
case DisclosureService.Capability item -> item.capabilityId();
|
||||
default -> throw new IllegalArgumentException("Unsupported control record type");
|
||||
};
|
||||
}
|
||||
|
||||
private static void requireSame(String first, String second) {
|
||||
if (!Objects.equals(first, second)) throw new IllegalArgumentException("Control record identity mismatch");
|
||||
}
|
||||
|
||||
private static void requirePrincipalReference(Set<String> principals, String principalId) {
|
||||
if (!principals.contains(principalId)) {
|
||||
throw new IllegalStateException("Server-control record references an unavailable principal");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (closed) throw new IllegalStateException("Server-control store is closed");
|
||||
}
|
||||
|
||||
private static void writeRealm(DataOutputStream out, RealmRecord value) throws IOException {
|
||||
out.writeInt(KIND_REALM); writeString(out, value.realmId().value()); writeString(out, value.displayName());
|
||||
out.writeInt(value.exposure().mode().code()); out.writeBoolean(value.exposure().authorityCreationPermitted());
|
||||
writePkiSet(out, value.exposure().authorityIds()); writeString(out, value.authorizationCommitment());
|
||||
writeString(out, value.approvalCommitment()); writeString(out, value.disclosureCommitment());
|
||||
writeString(out, value.controlStoreId().value());
|
||||
}
|
||||
|
||||
private static RealmRecord readRealm(DataInputStream in) throws IOException {
|
||||
RealmId realm = new RealmId(readString(in)); String displayName = readString(in);
|
||||
AuthorityExposurePolicy.Mode mode = AuthorityExposurePolicy.Mode.fromCode(in.readInt());
|
||||
boolean creation = in.readBoolean(); Set<PkiId> ids = readPkiSet(in);
|
||||
return new RealmRecord(realm, displayName, new AuthorityExposurePolicy(mode, ids, creation),
|
||||
readString(in), readString(in), readString(in), new MetadataStoreId(readString(in)));
|
||||
}
|
||||
|
||||
private static void writePrincipal(DataOutputStream out, SecurityPrincipal value) throws IOException {
|
||||
out.writeInt(KIND_PRINCIPAL); writeString(out, value.principalId()); out.writeInt(value.type().code());
|
||||
writeString(out, value.displayName()); writeOptionalString(out, value.organization()); writeStringMap(out, value.attributes());
|
||||
out.writeBoolean(value.enabled());
|
||||
}
|
||||
|
||||
private static SecurityPrincipal readPrincipal(DataInputStream in) throws IOException {
|
||||
return new SecurityPrincipal(readString(in), SecurityPrincipal.Type.fromCode(in.readInt()), readString(in),
|
||||
readOptionalString(in), readStringMap(in), in.readBoolean());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private static void writeGrant(DataOutputStream out, Permission.Grant value) throws IOException {
|
||||
out.writeInt(KIND_GRANT); writeGrantBody(out, value);
|
||||
}
|
||||
|
||||
private static Permission.Grant readGrant(DataInputStream in) throws IOException { return readGrantBody(in); }
|
||||
|
||||
private static void writeGrantBody(DataOutputStream out, Permission.Grant value) throws IOException {
|
||||
writeString(out, value.grantId()); writeString(out, value.principalId()); out.writeInt(value.effect().code());
|
||||
out.writeInt(value.action().code()); out.writeInt(value.resourceType().code()); writeScope(out, value.scope());
|
||||
out.writeInt(value.relationship().code()); out.writeInt(value.dataView().code());
|
||||
List<Permission.Condition> conditions = value.conditions().stream().sorted(Comparator.comparingInt(Permission.Condition::code)).toList();
|
||||
out.writeInt(conditions.size()); for (Permission.Condition item : conditions) out.writeInt(item.code());
|
||||
writeOptionalInstant(out, value.expiresAt()); out.writeBoolean(value.enabled());
|
||||
}
|
||||
|
||||
private static Permission.Grant readGrantBody(DataInputStream in) throws IOException {
|
||||
String id = readString(in); String principal = readString(in); Permission.Effect effect = Permission.Effect.fromCode(in.readInt());
|
||||
Permission.Action action = Permission.Action.fromCode(in.readInt()); Permission.ResourceType type = Permission.ResourceType.fromCode(in.readInt());
|
||||
Permission.Scope scope = readScope(in); Permission.Relationship relationship = Permission.Relationship.fromCode(in.readInt());
|
||||
Permission.DataView view = Permission.DataView.fromCode(in.readInt()); int count = readCount(in); Set<Permission.Condition> conditions = new HashSet<>();
|
||||
for (int index = 0; index < count; index++) if (!conditions.add(Permission.Condition.fromCode(in.readInt()))) throw new IllegalArgumentException("Duplicate grant condition");
|
||||
return new Permission.Grant(id, principal, effect, action, type, scope, relationship, view, conditions,
|
||||
readOptionalInstant(in), in.readBoolean());
|
||||
}
|
||||
|
||||
private static void writeApproval(DataOutputStream out, ApprovalService.Request value) throws IOException {
|
||||
out.writeInt(KIND_APPROVAL); writeString(out, value.approvalId()); writeString(out, value.operationId());
|
||||
writeString(out, value.operationCommitment()); writeScope(out, value.scope()); writeString(out, value.requesterPrincipalId());
|
||||
writeInstant(out, value.createdAt()); writeInstant(out, value.expiresAt()); writeApprovalPolicy(out, value.policy());
|
||||
writeString(out, value.policyCommitment()); out.writeInt(value.state().code()); out.writeInt(value.decisions().size());
|
||||
for (ApprovalService.Decision decision : value.decisions()) { writeString(out, decision.principalId()); out.writeInt(decision.choice().code());
|
||||
writeString(out, decision.justification()); writeInstant(out, decision.decidedAt()); }
|
||||
writeOptionalString(out, value.resultClassification());
|
||||
}
|
||||
|
||||
private static ApprovalService.Request readApproval(DataInputStream in) throws IOException {
|
||||
String id = readString(in); String operation = readString(in); String commitment = readString(in); Permission.Scope scope = readScope(in);
|
||||
String requester = readString(in); Instant created = readInstant(in); Instant expires = readInstant(in); ApprovalService.Policy policy = readApprovalPolicy(in);
|
||||
String policyCommitment = readString(in); ApprovalService.State state = ApprovalService.State.fromCode(in.readInt()); int count = readCount(in);
|
||||
List<ApprovalService.Decision> decisions = new ArrayList<>();
|
||||
for (int index = 0; index < count; index++) decisions.add(new ApprovalService.Decision(readString(in), ApprovalService.Choice.fromCode(in.readInt()), readString(in), readInstant(in)));
|
||||
return new ApprovalService.Request(id, operation, commitment, scope, requester, created, expires, policy,
|
||||
policyCommitment, state, decisions, readOptionalString(in));
|
||||
}
|
||||
|
||||
private static void writeApprovalPolicy(DataOutputStream out, ApprovalService.Policy value) throws IOException {
|
||||
writeString(out, value.policyId()); out.writeInt(value.threshold()); writeStringSet(out, value.eligibleApprovers());
|
||||
writeStringSet(out, value.requiredRoleTemplateIds()); out.writeBoolean(value.requesterSeparation());
|
||||
out.writeLong(value.lifetime().toSeconds()); out.writeBoolean(value.justificationRequired());
|
||||
}
|
||||
|
||||
private static ApprovalService.Policy readApprovalPolicy(DataInputStream in) throws IOException {
|
||||
return new ApprovalService.Policy(readString(in), in.readInt(), readStringSet(in), readStringSet(in),
|
||||
in.readBoolean(), java.time.Duration.ofSeconds(in.readLong()), in.readBoolean());
|
||||
}
|
||||
|
||||
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());
|
||||
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),
|
||||
BreakGlassService.State.fromCode(in.readInt()), readString(in));
|
||||
}
|
||||
|
||||
private static void writeDisclosure(DataOutputStream out, DisclosureService.Record value) throws IOException {
|
||||
out.writeInt(KIND_DISCLOSURE); writeString(out, value.objectId().value()); out.writeInt(value.objectType().code());
|
||||
out.writeInt(value.policy().code()); writeOptionalString(out, value.ownerPrincipalId());
|
||||
writeString(out, value.policyCommitment()); writeInstant(out, value.updatedAt());
|
||||
}
|
||||
|
||||
private static DisclosureService.Record readDisclosure(DataInputStream in) throws IOException {
|
||||
return new DisclosureService.Record(new PkiId(readString(in)), DisclosureService.ObjectType.fromCode(in.readInt()),
|
||||
DisclosureService.Policy.fromCode(in.readInt()), readOptionalString(in), readString(in), readInstant(in));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
|
||||
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
|
||||
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
|
||||
}
|
||||
|
||||
private static Permission.Scope readScope(DataInputStream in) throws IOException {
|
||||
return new Permission.Scope(new RealmId(readString(in)), readOptionalPki(in), readOptionalPki(in), readOptionalString(in));
|
||||
}
|
||||
|
||||
private static void writeString(DataOutputStream out, String value) throws IOException {
|
||||
byte[] bytes = Objects.requireNonNull(value, "value").getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAXIMUM_STRING_BYTES) throw new IllegalArgumentException("Control string too long");
|
||||
out.writeInt(bytes.length); out.write(bytes);
|
||||
}
|
||||
|
||||
private static String readString(DataInputStream in) throws IOException {
|
||||
int length = in.readInt(); if (length < 0 || length > MAXIMUM_STRING_BYTES) throw new IllegalArgumentException("Control string length invalid");
|
||||
byte[] bytes = in.readNBytes(length); if (bytes.length != length) throw new IllegalArgumentException("Truncated control string");
|
||||
String result = new String(bytes, StandardCharsets.UTF_8);
|
||||
if (!java.util.Arrays.equals(bytes, result.getBytes(StandardCharsets.UTF_8))) throw new IllegalArgumentException("Control string UTF-8 invalid");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void writeOptionalString(DataOutputStream out, Optional<String> value) throws IOException {
|
||||
out.writeBoolean(value.isPresent()); if (value.isPresent()) writeString(out, value.orElseThrow());
|
||||
}
|
||||
private static Optional<String> readOptionalString(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(readString(in)) : Optional.empty(); }
|
||||
private static void writeOptionalPki(DataOutputStream out, Optional<PkiId> value) throws IOException { writeOptionalString(out, value.map(PkiId::value)); }
|
||||
private static Optional<PkiId> readOptionalPki(DataInputStream in) throws IOException { return readOptionalString(in).map(PkiId::new); }
|
||||
private static void writeInstant(DataOutputStream out, Instant value) throws IOException { out.writeLong(value.toEpochMilli()); }
|
||||
private static Instant readInstant(DataInputStream in) throws IOException { return Instant.ofEpochMilli(in.readLong()); }
|
||||
private static void writeOptionalInstant(DataOutputStream out, Optional<Instant> value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) writeInstant(out, value.orElseThrow()); }
|
||||
private static Optional<Instant> readOptionalInstant(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(readInstant(in)) : Optional.empty(); }
|
||||
private static void writePkiSet(DataOutputStream out, Set<PkiId> values) throws IOException { List<PkiId> sorted = values.stream().sorted(Comparator.comparing(PkiId::value)).toList(); out.writeInt(sorted.size()); for (PkiId value : sorted) writeString(out, value.value()); }
|
||||
private static Set<PkiId> readPkiSet(DataInputStream in) throws IOException { int count = readCount(in); Set<PkiId> result = new HashSet<>(); for (int index = 0; index < count; index++) if (!result.add(new PkiId(readString(in)))) throw new IllegalArgumentException("Duplicate authority identity"); return result; }
|
||||
private static void writeStringSet(DataOutputStream out, Set<String> values) throws IOException { List<String> sorted = values.stream().sorted().toList(); out.writeInt(sorted.size()); for (String value : sorted) writeString(out, value); }
|
||||
private static Set<String> readStringSet(DataInputStream in) throws IOException { int count = readCount(in); Set<String> result = new HashSet<>(); for (int index = 0; index < count; index++) if (!result.add(readString(in))) throw new IllegalArgumentException("Duplicate control set value"); return result; }
|
||||
private static void writeStringMap(DataOutputStream out, Map<String, String> values) throws IOException { List<Map.Entry<String, String>> sorted = values.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList(); out.writeInt(sorted.size()); for (Map.Entry<String, String> entry : sorted) { writeString(out, entry.getKey()); writeString(out, entry.getValue()); } }
|
||||
private static Map<String, String> readStringMap(DataInputStream in) throws IOException { int count = readCount(in); Map<String, String> result = new LinkedHashMap<>(); for (int index = 0; index < count; index++) if (result.put(readString(in), readString(in)) != null) throw new IllegalArgumentException("Duplicate control map key"); return Map.copyOf(result); }
|
||||
private static int readCount(DataInputStream in) throws IOException { int count = in.readInt(); if (count < 0 || count > MAXIMUM_COLLECTION) throw new IllegalArgumentException("Control collection size invalid"); return count; }
|
||||
private static void writeBytes(DataOutputStream out, byte[] value) throws IOException { out.writeInt(value.length); out.write(value); }
|
||||
private static byte[] readBytes(DataInputStream in, int expected) throws IOException { int length = in.readInt(); if (length != expected) throw new IllegalArgumentException("Control byte value length invalid"); byte[] value = in.readNBytes(length); if (value.length != length) throw new IllegalArgumentException("Truncated control byte value"); return value; }
|
||||
private static void requireDigest(String value) { if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Control commitment invalid"); }
|
||||
|
||||
@FunctionalInterface private interface Encoder { void write(DataOutputStream output) throws IOException; }
|
||||
@FunctionalInterface private interface Decoder<T> { T read(DataInputStream input) throws IOException; }
|
||||
|
||||
private record ByteContent(byte[] value) implements RepeatableContent {
|
||||
private ByteContent { value = value.clone(); }
|
||||
@Override public InputStream openStream() { return new ByteArrayInputStream(value); }
|
||||
@Override public OptionalLong length() { return OptionalLong.of(value.length); }
|
||||
@Override public String contentId() { return "server-control-record"; }
|
||||
@Override public void close() { }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*******************************************************************************
|
||||
* 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.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import zeroecho.pki.application.PkiSessionConfiguration;
|
||||
import zeroecho.pki.server.OperationSecurityDescriptors.ApprovalCategory;
|
||||
import zeroecho.pki.spi.store.MetadataStoreId;
|
||||
|
||||
/**
|
||||
* Immutable transport-neutral configuration for one server realm.
|
||||
*
|
||||
* <p>The realm identity and server-control identity are explicit values and are
|
||||
* never derived from paths, listeners, display names, or Java types. Provider
|
||||
* configuration values may contain sensitive references and must not be logged
|
||||
* or rendered.</p>
|
||||
*
|
||||
* @param realmId canonical realm identity
|
||||
* @param displayName safe finite administrative display name
|
||||
* @param pkiSessionConfiguration shared PKI session configuration
|
||||
* @param authorityExposure explicit process authority-exposure policy
|
||||
* @param authorizationCommitment SHA-256 commitment to authorization configuration
|
||||
* @param approvalCommitment SHA-256 commitment to approval configuration
|
||||
* @param disclosureCommitment SHA-256 commitment to disclosure defaults
|
||||
* @param disclosureDefaults safe object-disclosure defaults
|
||||
* @param controlLogPath dedicated server-control metadata log
|
||||
* @param controlStoreId explicit durable server-control identity
|
||||
* @param approvalPolicies closed approval policies by operation category
|
||||
*/
|
||||
public record ServerRealmConfiguration(RealmId realmId, String displayName,
|
||||
PkiSessionConfiguration pkiSessionConfiguration, AuthorityExposurePolicy authorityExposure,
|
||||
String authorizationCommitment, String approvalCommitment, String disclosureCommitment,
|
||||
DisclosureService.Defaults disclosureDefaults, Path controlLogPath, MetadataStoreId controlStoreId,
|
||||
Map<ApprovalCategory, ApprovalService.Policy> approvalPolicies) {
|
||||
|
||||
/** Validates and snapshots the complete configuration before allocation. */
|
||||
public ServerRealmConfiguration {
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Permission.requireBounded(displayName, 256, "realm display name");
|
||||
Objects.requireNonNull(pkiSessionConfiguration, "pkiSessionConfiguration");
|
||||
Objects.requireNonNull(authorityExposure, "authorityExposure");
|
||||
requireDigest(authorizationCommitment, "authorization commitment");
|
||||
requireDigest(approvalCommitment, "approval commitment");
|
||||
requireDigest(disclosureCommitment, "disclosure commitment");
|
||||
Objects.requireNonNull(disclosureDefaults, "disclosureDefaults");
|
||||
controlLogPath = Objects.requireNonNull(controlLogPath, "controlLogPath").toAbsolutePath().normalize();
|
||||
if (controlLogPath.getParent() == null) {
|
||||
throw new IllegalArgumentException("Server-control log requires a parent directory");
|
||||
}
|
||||
Objects.requireNonNull(controlStoreId, "controlStoreId");
|
||||
approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
|
||||
ApprovalService.Policy highRisk = approvalPolicies.get(ApprovalCategory.HIGH_RISK);
|
||||
if (highRisk == null || !highRisk.commitment().equals(approvalCommitment)) {
|
||||
throw new IllegalArgumentException("High-risk approval policy commitment is inconsistent");
|
||||
}
|
||||
if (approvalPolicies.containsKey(ApprovalCategory.NONE)) {
|
||||
throw new IllegalArgumentException("Ordinary operations cannot have an implicit approval policy");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireDigest(String value, String name) {
|
||||
if (value == null || !value.matches("[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException(name + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*******************************************************************************
|
||||
* 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.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import zeroecho.pki.application.PkiSession;
|
||||
import zeroecho.pki.application.PkiOperation;
|
||||
import zeroecho.pki.application.PkiOperationOutcome;
|
||||
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
|
||||
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
|
||||
|
||||
/**
|
||||
* Lifecycle owner for one server realm, one long-lived PKI session, and one
|
||||
* dedicated durable server-control authority.
|
||||
*
|
||||
* <p>The context is transport-neutral and synchronous. It creates no execution
|
||||
* lane, queue, thread, scheduler, retry loop, HTTP type, or ACME object. A future
|
||||
* transport can retain one instance for its process lifetime and submit existing
|
||||
* typed operations through {@link #gateway()}.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "PMD.CommentDefaultAccessModifier", "PMD.CloseResource", "PMD.UseProperClassLoader",
|
||||
"PMD.AvoidCatchingGenericException", "PMD.LinguisticNaming", "PMD.ControlStatementBraces",
|
||||
"PMD.PreserveStackTrace", "PMD.SignatureDeclareThrowsException", "PMD.CommentRequired",
|
||||
"PMD.AvoidSynchronizedAtMethodLevel", "PMD.AvoidInstantiatingObjectsInLoops" })
|
||||
public final class ServerRealmContext implements AutoCloseable {
|
||||
/** Closed lifecycle states. */
|
||||
public enum State { OPEN, CLOSING, CLOSED }
|
||||
|
||||
private final ServerRealmConfiguration configuration;
|
||||
private final ServerControlStore control;
|
||||
private final PkiSession session;
|
||||
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 ServerOperationGateway gateway;
|
||||
private final SafeAudit audit;
|
||||
private final AtomicReference<State> state = new AtomicReference<>(State.OPEN);
|
||||
|
||||
private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control,
|
||||
PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization,
|
||||
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
|
||||
AuditorViews auditorViews, AuditSink auditSink, Clock clock) {
|
||||
this.configuration = configuration;
|
||||
this.control = control;
|
||||
this.session = session;
|
||||
this.roles = roles;
|
||||
this.authorization = authorization;
|
||||
this.approvals = approvals;
|
||||
this.breakGlass = breakGlass;
|
||||
this.disclosure = disclosure;
|
||||
this.auditorViews = auditorViews;
|
||||
this.audit = new SafeAudit(clock, auditSink);
|
||||
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
|
||||
control, roles, authorization, approvals, breakGlass, new OperationSecurityDescriptors(),
|
||||
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
|
||||
this::requireOpen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one production realm using explicit process-local capabilities.
|
||||
*
|
||||
* @param configuration validated immutable realm configuration
|
||||
* @param dependencies key-access capabilities, never secret values
|
||||
* @return fully recovered realm context
|
||||
*/
|
||||
public static ServerRealmContext open(ServerRealmConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies dependencies) {
|
||||
return open(configuration, dependencies, Clock.systemUTC(), new SecureRandom());
|
||||
}
|
||||
|
||||
static ServerRealmContext open(ServerRealmConfiguration configuration,
|
||||
PkiSessionRuntimeDependencies dependencies, Clock clock, SecureRandom random) {
|
||||
ServerRealmConfiguration exact = Objects.requireNonNull(configuration, "configuration");
|
||||
PkiSessionRuntimeDependencies runtime = Objects.requireNonNull(dependencies, "dependencies");
|
||||
Objects.requireNonNull(clock, "clock");
|
||||
Objects.requireNonNull(random, "random");
|
||||
if (Files.isSymbolicLink(exact.controlLogPath())) {
|
||||
throw new IllegalArgumentException("Server-control log must not be a symbolic link");
|
||||
}
|
||||
ServerControlStore control = null;
|
||||
SharedAuditSink audit = null;
|
||||
PkiSession session = null;
|
||||
try {
|
||||
control = new ServerControlStore(openControl(exact));
|
||||
ServerControlStore.RealmRecord realm = new ServerControlStore.RealmRecord(exact.realmId(),
|
||||
exact.displayName(), exact.authorityExposure(), exact.authorizationCommitment(),
|
||||
exact.approvalCommitment(), exact.disclosureCommitment(), exact.controlStoreId());
|
||||
control.ensureRealm(realm);
|
||||
RoleTemplateCatalog roles = RoleTemplateCatalog.load(ServerRealmContext.class.getClassLoader());
|
||||
control.validateAll();
|
||||
control.validateReferences(roles);
|
||||
audit = new SharedAuditSink(PkiBootstrap.openAudit(exact.pkiSessionConfiguration().audit()));
|
||||
session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit));
|
||||
validateExposure(exact.authorityExposure(), session);
|
||||
AuthorizationEngine authorization = new AuthorizationEngine(clock);
|
||||
ApprovalService approvals = new ApprovalService(control, clock, audit);
|
||||
BreakGlassService breakGlass = new BreakGlassService(control, clock, audit);
|
||||
DisclosureService disclosure = new DisclosureService(exact.realmId(), control,
|
||||
exact.disclosureDefaults(), clock, random, audit);
|
||||
AuditorViews views = new AuditorViews(clock, audit);
|
||||
ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization,
|
||||
approvals, breakGlass, disclosure, views, audit, clock);
|
||||
result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value()));
|
||||
return result;
|
||||
} catch (RuntimeException | Error primary) {
|
||||
closeAfterFailure(session, audit, control, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return exact realm configuration without rendering provider values */
|
||||
public ServerRealmConfiguration configuration() { requireOpen(); return configuration; }
|
||||
/** @return the one long-lived PKI session */
|
||||
public PkiSession session() { requireOpen(); return session; }
|
||||
/** @return immutable built-in role template catalog */
|
||||
public RoleTemplateCatalog roleTemplates() { requireOpen(); return roles; }
|
||||
/** @return deterministic authorization engine */
|
||||
public AuthorizationEngine authorization() { requireOpen(); return authorization; }
|
||||
/** @return durable approval service */
|
||||
public ApprovalService approvals() { requireOpen(); return approvals; }
|
||||
/** @return durable break-glass service */
|
||||
public BreakGlassService breakGlass() { requireOpen(); return breakGlass; }
|
||||
/** @return durable disclosure decision service */
|
||||
public DisclosureService disclosure() { requireOpen(); return disclosure; }
|
||||
/** @return explicit auditor projection service */
|
||||
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
|
||||
/** @return authorized typed-operation gateway */
|
||||
public ServerOperationGateway gateway() { requireOpen(); return gateway; }
|
||||
/** @return current lifecycle state */
|
||||
public State state() { return state.get(); }
|
||||
|
||||
/** Creates a principal and records only safe administrative audit metadata. */
|
||||
public void createPrincipal(SecurityPrincipal principal, String actorPrincipalId) {
|
||||
requireOpen();
|
||||
control.createPrincipal(principal);
|
||||
audit.record("PRINCIPAL_CREATE", actorPrincipalId, Optional.empty(),
|
||||
Map.of("principal", principal.principalId(), "enabled", Boolean.toString(principal.enabled())));
|
||||
}
|
||||
|
||||
/** Changes only the enabled state of an existing principal. */
|
||||
public SecurityPrincipal setPrincipalEnabled(String principalId, boolean enabled, String actorPrincipalId) {
|
||||
requireOpen();
|
||||
SecurityPrincipal current = control.requirePrincipal(principalId);
|
||||
SecurityPrincipal updated = new SecurityPrincipal(current.principalId(), current.type(),
|
||||
current.displayName(), current.organization(), current.attributes(), enabled);
|
||||
control.replacePrincipal(current, updated);
|
||||
audit.record("PRINCIPAL_STATE", actorPrincipalId, Optional.empty(),
|
||||
Map.of("principal", principalId, "enabled", Boolean.toString(enabled)));
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Creates one scoped role assignment; templates remain non-authoritative alone. */
|
||||
public void assignRole(RoleTemplateCatalog.Assignment assignment, String actorPrincipalId) {
|
||||
requireOpen();
|
||||
roles.instantiate(assignment);
|
||||
control.requirePrincipal(assignment.principalId());
|
||||
control.createAssignment(assignment);
|
||||
audit.record("ROLE_ASSIGNMENT_CREATE", actorPrincipalId, Optional.empty(),
|
||||
Map.of("assignment", assignment.assignmentId(), "template", assignment.templateId()));
|
||||
}
|
||||
|
||||
/** Creates one explicit scoped direct grant. */
|
||||
public void grant(Permission.Grant grant, String actorPrincipalId) {
|
||||
requireOpen();
|
||||
control.requirePrincipal(grant.principalId());
|
||||
control.createGrant(grant);
|
||||
audit.record("PERMISSION_GRANT_CREATE", actorPrincipalId, Optional.empty(),
|
||||
Map.of("grant", grant.grantId(), "effect", grant.effect().name()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects new work and closes session then server-control authority. Repeated
|
||||
* calls are harmless and failure suppression preserves causal order.
|
||||
*/
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (!state.compareAndSet(State.OPEN, State.CLOSING)) return;
|
||||
Throwable primary = null;
|
||||
try {
|
||||
audit.record("REALM_CLOSE", "system", Optional.empty(), Map.of("realm", configuration.realmId().value()));
|
||||
} catch (Throwable failure) {
|
||||
primary = failure;
|
||||
}
|
||||
primary = closeOne(session, primary);
|
||||
primary = closeOne(control, primary);
|
||||
state.set(State.CLOSED);
|
||||
rethrow(primary);
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (state.get() != State.OPEN) throw new IllegalStateException("Server realm is not open");
|
||||
}
|
||||
|
||||
private static PosixTransactionalMetadataStore openControl(ServerRealmConfiguration configuration) {
|
||||
try {
|
||||
if (Files.exists(configuration.controlLogPath())) {
|
||||
PosixTransactionalMetadataStore opened = PosixTransactionalMetadataStore.open(
|
||||
configuration.controlLogPath(), OptionalLong.of(1_048_576));
|
||||
if (!opened.id().equals(configuration.controlStoreId())) {
|
||||
opened.close();
|
||||
throw new IllegalStateException("Server-control store identity differs");
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
return PosixTransactionalMetadataStore.create(configuration.controlLogPath(),
|
||||
configuration.controlStoreId(), OptionalLong.of(1_048_576));
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Server-control metadata authority cannot be opened");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateExposure(AuthorityExposurePolicy exposure, PkiSession session) {
|
||||
if (exposure.mode() == AuthorityExposurePolicy.Mode.EXPLICIT_AUTHORITIES) {
|
||||
for (zeroecho.pki.api.PkiId authorityId : exposure.authorityIds()) {
|
||||
PkiOperationOutcome outcome = session.operations().execute(
|
||||
new PkiOperation.InspectAuthority(authorityId), zeroecho.core.io.CancellationSignal.NONE);
|
||||
if (!(outcome instanceof PkiOperationOutcome.Success)) {
|
||||
throw new IllegalStateException("Configured authority exposure contains an unavailable authority");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeAfterFailure(PkiSession session, AuditSink audit, ServerControlStore control,
|
||||
Throwable primary) {
|
||||
Throwable result = closeOne(session, primary);
|
||||
if (session == null) result = closeOne(audit, result);
|
||||
closeOne(control, result);
|
||||
}
|
||||
|
||||
private static Throwable closeOne(AutoCloseable resource, Throwable primary) {
|
||||
if (resource == null) return primary;
|
||||
try {
|
||||
resource.close();
|
||||
} catch (Throwable failure) {
|
||||
if (primary == null) return failure;
|
||||
primary.addSuppressed(failure);
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static void rethrow(Throwable failure) throws Exception {
|
||||
if (failure == null) return;
|
||||
if (failure instanceof Exception exception) throw exception;
|
||||
if (failure instanceof Error error) throw error;
|
||||
throw new IllegalStateException("Unexpected realm close failure");
|
||||
}
|
||||
|
||||
private static final class SharedAuditSink implements AuditSink {
|
||||
private final AuditSink delegate;
|
||||
private boolean closed;
|
||||
|
||||
private SharedAuditSink(AuditSink delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); }
|
||||
@Override public synchronized void record(zeroecho.pki.api.audit.AuditEvent event) {
|
||||
if (closed) throw new IllegalStateException("Audit sink is closed");
|
||||
delegate.record(event);
|
||||
}
|
||||
@Override public synchronized void close() {
|
||||
if (!closed) { closed = true; delegate.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user