diff --git a/docs/pki-server-admin-https.md b/docs/pki-server-admin-https.md index 6d2a554..3ce24fa 100644 --- a/docs/pki-server-admin-https.md +++ b/docs/pki-server-admin-https.md @@ -45,3 +45,25 @@ Shutdown first removes readiness and admission, stops listener acceptance, drain ## Request correlation Clients may supply `X-ZeroEcho-Request-Id` using 16–128 ASCII letters, digits, underscore, or hyphen. Otherwise the server creates a random opaque identifier. It is correlation metadata—not an idempotency key, object identity, approval, or capability token. + +## Security-control administration + +The operation catalog is the single administrative namespace for both existing +PKI operations and closed `SERVER_CONTROL_OPERATION` requests. Subject to scoped +authorization, it exposes principal lifecycle, immutable role-template +inspection, scoped assignments and grants, approval decisions, break-glass +lifecycle, disclosure policy, and auditor projections. Lists are explicitly +paged; revocation disables a control record without erasing its history. + +High-risk changes use a separately created approval request committed to the +exact typed target operation and scope. An approval decision never executes the +target. The later ordinary operation request supplies the approval ID and the +gateway claims and completes it exactly once. + +`security.disclosure.capability.issue` is the only one-time-sensitive response. +The durable store receives the token commitment before the raw 256-bit token is +returned. Its HTTPS response additionally carries `Cache-Control: no-store`, +`Pragma: no-cache`, and `Referrer-Policy: no-referrer`. The token is absent from +catalogs, inspection results and audit details. A response-write failure is +classified as delivery-unknown; the server neither regenerates nor retries the +token automatically. diff --git a/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java new file mode 100644 index 0000000..0877b52 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/AdministrativeOperation.java @@ -0,0 +1,59 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import java.util.Objects; + +import zeroecho.pki.application.PkiOperation; + +/** One closed administrative namespace spanning PKI and server-control families. */ +public sealed interface AdministrativeOperation permits AdministrativeOperation.Pki, + AdministrativeOperation.Control { + /** @return stable operation identity */ + String name(); + + /** Existing transport-neutral PKI operation family. */ + record Pki(PkiOperation operation) implements AdministrativeOperation { + /** Validates the typed PKI operation. */ + public Pki { Objects.requireNonNull(operation, "operation"); } + @Override public String name() { return operation.name(); } + } + + /** Server security-control operation family. */ + record Control(ServerControlOperation operation) implements AdministrativeOperation { + /** Validates the typed control operation. */ + public Control { Objects.requireNonNull(operation, "operation"); } + @Override public String name() { return operation.name(); } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java index 10d11c9..9cc6128 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java +++ b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java @@ -43,17 +43,18 @@ import java.util.Optional; /** Durable, scoped, mandatory-expiry emergency authorization service. */ @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass", - "PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" }) + "PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel", + "PMD.ExcessiveParameterList" }) public final class BreakGlassService { /** Break-glass lifecycle states. */ public enum State { - ACTIVE(1), REVOKED(2), EXPIRED(3); + ACTIVE(1), REVOKED(2), EXPIRED(3), PENDING(4); private final int code; State(int code) { this.code = code; } /** @return stable persistence code */ public int code() { return code; } /** Resolves one stable persistence code. */ public static State fromCode(int code) { - return switch (code) { case 1 -> ACTIVE; case 2 -> REVOKED; case 3 -> EXPIRED; + return switch (code) { case 1 -> ACTIVE; case 2 -> REVOKED; case 3 -> EXPIRED; case 4 -> PENDING; default -> throw new IllegalArgumentException("Unknown break-glass state code"); }; } } @@ -74,7 +75,7 @@ public final class BreakGlassService { * @param auditCommitment stable safe commitment */ public record Record(String breakGlassId, String principalId, Permission.Grant grant, String reason, - String issuerPrincipalId, Optional approvalId, Instant createdAt, Instant activatedAt, + String issuerPrincipalId, Optional approvalId, Instant createdAt, Optional activatedAt, Instant expiresAt, State state, String auditCommitment) { /** Validates the strict time-limited scoped record. */ public Record { @@ -91,17 +92,30 @@ public final class BreakGlassService { } approvalId = Objects.requireNonNull(approvalId, "approvalId"); Objects.requireNonNull(createdAt, "createdAt"); - Objects.requireNonNull(activatedAt, "activatedAt"); + activatedAt = Objects.requireNonNull(activatedAt, "activatedAt"); Objects.requireNonNull(expiresAt, "expiresAt"); - if (activatedAt.isBefore(createdAt) || !expiresAt.isAfter(activatedAt) - || Duration.between(activatedAt, expiresAt).compareTo(Duration.ofHours(24)) > 0) { + Instant lifetimeStart = activatedAt.orElse(createdAt); + if (activatedAt.filter(value -> value.isBefore(createdAt)).isPresent() || !expiresAt.isAfter(lifetimeStart) + || Duration.between(lifetimeStart, expiresAt).compareTo(Duration.ofHours(24)) > 0) { throw new IllegalArgumentException("Break-glass lifetime is invalid"); } Objects.requireNonNull(state, "state"); + if (state == State.PENDING && activatedAt.isPresent() + || state == State.ACTIVE && activatedAt.isEmpty()) { + throw new IllegalArgumentException("Break-glass activation state is invalid"); + } if (auditCommitment == null || !auditCommitment.matches("[0-9a-f]{64}")) { throw new IllegalArgumentException("Break-glass audit commitment is invalid"); } } + + /** Creates an activated record for existing embedded callers. */ + public Record(String breakGlassId, String principalId, Permission.Grant grant, String reason, + String issuerPrincipalId, Optional approvalId, Instant createdAt, Instant activatedAt, + Instant expiresAt, State state, String auditCommitment) { + this(breakGlassId, principalId, grant, reason, issuerPrincipalId, approvalId, createdAt, + Optional.of(activatedAt), expiresAt, state, auditCommitment); + } } /** Active grants plus their identities for authorization and use auditing. */ @@ -124,21 +138,38 @@ public final class BreakGlassService { this.audit = new SafeAudit(clock, auditSink); } - /** Creates and immediately activates one independently issued emergency grant. */ - public synchronized Record activate(String breakGlassId, String principalId, Permission.Grant grant, String reason, + /** Creates one durable pending emergency grant without activating it. */ + public synchronized Record create(String breakGlassId, String principalId, Permission.Grant grant, String reason, String issuerPrincipalId, Optional approvalId, Instant expiresAt) { - store.requirePrincipal(principalId); - store.requirePrincipal(issuerPrincipalId); + store.requirePrincipal(principalId); store.requirePrincipal(issuerPrincipalId); + requireExecutingApproval(approvalId, ServerControlOperation.CreateBreakGlass.CREATE, grant.scope()); Instant now = clock.instant(); String commitment = ApprovalService.digest(breakGlassId + '|' + principalId + '|' + grant.grantId() + '|' + issuerPrincipalId + '|' + now + '|' + expiresAt); Record record = new Record(breakGlassId, principalId, grant, reason, issuerPrincipalId, approvalId, - now, now, expiresAt, State.ACTIVE, commitment); + now, Optional.empty(), expiresAt, State.PENDING, commitment); store.createBreakGlass(record); - audit.record("BREAK_GLASS_ACTIVATE", issuerPrincipalId, Optional.empty(), Map.of("state", "ACTIVE")); + audit.record("BREAK_GLASS_CREATE", issuerPrincipalId, Optional.empty(), Map.of("state", "PENDING")); return record; } + /** Activates one independently approved pending emergency grant. */ + public synchronized Record activatePending(String breakGlassId, String actorPrincipalId, + Optional approvalId) { + Record current = requireCurrent(breakGlassId); + if (current.state() != State.PENDING) { + throw new IllegalStateException("Break-glass record is not approved for activation"); + } + requireExecutingApproval(approvalId, ServerControlOperation.ActivateBreakGlass.ACTIVATE, + current.grant().scope()); + Record active = new Record(current.breakGlassId(), current.principalId(), current.grant(), current.reason(), + current.issuerPrincipalId(), approvalId, current.createdAt(), Optional.of(clock.instant()), + current.expiresAt(), State.ACTIVE, current.auditCommitment()); + store.replaceBreakGlass(current, active); + audit.record("BREAK_GLASS_ACTIVATE", actorPrincipalId, Optional.empty(), Map.of("state", "ACTIVE")); + return active; + } + /** Revokes an active grant. */ public synchronized Record revoke(String breakGlassId, String actorPrincipalId) { Record current = requireCurrent(breakGlassId); @@ -152,7 +183,8 @@ public final class BreakGlassService { /** Returns a current record, durably applying expiry when observed. */ public synchronized Record requireCurrent(String breakGlassId) { Record current = store.requireBreakGlass(breakGlassId); - if (current.state() == State.ACTIVE && !clock.instant().isBefore(current.expiresAt())) { + if ((current.state() == State.ACTIVE || current.state() == State.PENDING) + && !clock.instant().isBefore(current.expiresAt())) { Record expired = copy(current, State.EXPIRED); store.replaceBreakGlass(current, expired); audit.record("BREAK_GLASS_EXPIRY", current.principalId(), Optional.empty(), Map.of("state", "EXPIRED")); @@ -180,4 +212,14 @@ public final class BreakGlassService { source.issuerPrincipalId(), source.approvalId(), source.createdAt(), source.activatedAt(), source.expiresAt(), state, source.auditCommitment()); } + + private void requireExecutingApproval(Optional approvalId, String operationId, + Permission.Scope scope) { + ApprovalService.Request approval = store.requireApproval(approvalId + .orElseThrow(() -> new IllegalStateException("Break-glass approval is required"))); + if (approval.state() != ApprovalService.State.EXECUTING || !approval.operationId().equals(operationId) + || !approval.scope().equals(scope)) { + throw new IllegalStateException("Break-glass approval is not executable"); + } + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java index e281291..4b46d82 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java +++ b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java @@ -51,6 +51,18 @@ import zeroecho.pki.api.PkiId; @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition", "PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" }) public final class DisclosureService { + /** Durable one-time delivery classification without retaining the raw token. */ + public enum DeliveryState { + PENDING(1), DELIVERED(2), DELIVERY_UNKNOWN(3); + private final int code; + DeliveryState(int code) { this.code = code; } + /** @return stable persistence code */ public int code() { return code; } + /** Resolves one stable persistence code. */ + public static DeliveryState fromCode(int code) { + return switch (code) { case 1 -> PENDING; case 2 -> DELIVERED; case 3 -> DELIVERY_UNKNOWN; + default -> throw new IllegalArgumentException("Unknown capability delivery code"); }; + } + } /** Persisted disclosure states, independent of issuance and publication. */ public enum Policy { PUBLIC(1), PUBLIC_UNLISTED(2), AUTHENTICATED(3), OWNER_ONLY(4), RESTRICTED(5), NOT_PUBLISHED(6); @@ -133,32 +145,58 @@ public final class DisclosureService { /** Persisted commitment-only capability authority. */ public record Capability(String capabilityId, RealmId realmId, PkiId objectId, String action, - byte[] tokenCommitment, Instant expiresAt, boolean revoked) { + String issuerPrincipalId, byte[] tokenCommitment, Instant expiresAt, + DeliveryState deliveryState, boolean revoked) { /** Validates and snapshots capability metadata. */ public Capability { Permission.requireId(capabilityId, "capability"); Objects.requireNonNull(realmId, "realmId"); Objects.requireNonNull(objectId, "objectId"); Permission.requireBounded(action, 128, "capability action"); + Permission.requirePrincipal(issuerPrincipalId); tokenCommitment = Objects.requireNonNull(tokenCommitment, "tokenCommitment").clone(); if (tokenCommitment.length != 32) throw new IllegalArgumentException("Capability commitment length"); Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(deliveryState, "deliveryState"); } /** Returns a defensive commitment copy. */ @Override public byte[] tokenCommitment() { return tokenCommitment.clone(); } + + /** Creates legacy test metadata with an explicit non-secret system issuer. */ + public Capability(String capabilityId, RealmId realmId, PkiId objectId, String action, + byte[] tokenCommitment, Instant expiresAt, boolean revoked) { + this(capabilityId, realmId, objectId, action, "system", tokenCommitment, expiresAt, revoked); + } + + /** Creates capability metadata in the delivered state for embedded compatibility. */ + public Capability(String capabilityId, RealmId realmId, PkiId objectId, String action, + String issuerPrincipalId, byte[] tokenCommitment, Instant expiresAt, boolean revoked) { + this(capabilityId, realmId, objectId, action, issuerPrincipalId, tokenCommitment, expiresAt, + DeliveryState.DELIVERED, revoked); + } } /** One-time raw-token issuance result. */ - public record IssuedCapability(Capability capability, byte[] token) { + public static final class IssuedCapability { + private final Capability capability; + private final byte[] token; + private boolean consumed; /** Validates and snapshots the one-time token. */ - public IssuedCapability { - Objects.requireNonNull(capability, "capability"); - token = Objects.requireNonNull(token, "token").clone(); + public IssuedCapability(Capability capability, byte[] token) { + this.capability = Objects.requireNonNull(capability, "capability"); + this.token = Objects.requireNonNull(token, "token").clone(); if (token.length != 32) throw new IllegalArgumentException("Capability token length"); } - /** Returns a defensive one-time token copy. */ - @Override public byte[] token() { return token.clone(); } + /** @return persisted commitment-only metadata */ public Capability capability() { return capability; } + /** Returns and clears the raw token exactly once. */ + public synchronized byte[] token() { + if (consumed) throw new IllegalStateException("Capability token was already consumed"); + consumed = true; + byte[] result = token.clone(); + java.util.Arrays.fill(token, (byte) 0); + return result; + } } /** Safe retrieval result that reveals no protected-object existence. */ @@ -244,6 +282,7 @@ public final class DisclosureService { /** Issues 256 random capability bits and persists only their commitment. */ public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt, String actorPrincipalId) { + store.requirePrincipal(actorPrincipalId); Record record = store.requireDisclosure(objectId); if (record.policy() != Policy.PUBLIC_UNLISTED || !expiresAt.isAfter(clock.instant())) { throw new IllegalStateException("Capability issuance is unavailable for this object"); @@ -253,8 +292,8 @@ public final class DisclosureService { random.nextBytes(token); random.nextBytes(identity); String capabilityId = "cap-" + HexFormat.of().formatHex(identity); - Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE", - capabilityCommitment(objectId, expiresAt, token), expiresAt, false); + Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE", actorPrincipalId, + capabilityCommitment(objectId, expiresAt, token), expiresAt, DeliveryState.PENDING, false); store.createCapability(capability); audit.record("CAPABILITY_ISSUE", actorPrincipalId, Optional.of(objectId), Map.of("issued", "true")); return new IssuedCapability(capability, token); @@ -264,12 +303,26 @@ public final class DisclosureService { public synchronized Capability revokeCapability(String capabilityId, String actorPrincipalId) { Capability current = store.requireCapability(capabilityId); Capability revoked = new Capability(current.capabilityId(), current.realmId(), current.objectId(), - current.action(), current.tokenCommitment(), current.expiresAt(), true); + current.action(), current.issuerPrincipalId(), current.tokenCommitment(), current.expiresAt(), + current.deliveryState(), true); store.replaceCapability(current, revoked); audit.record("CAPABILITY_REVOKE", actorPrincipalId, Optional.of(current.objectId()), Map.of("revoked", "true")); return revoked; } + /** Persists the safe post-write delivery classification without changing token validity. */ + public synchronized Capability classifyDelivery(String capabilityId, boolean delivered) { + Capability current = store.requireCapability(capabilityId); + if (current.deliveryState() != DeliveryState.PENDING) return current; + Capability updated = new Capability(current.capabilityId(), current.realmId(), current.objectId(), + current.action(), current.issuerPrincipalId(), current.tokenCommitment(), current.expiresAt(), + delivered ? DeliveryState.DELIVERED : DeliveryState.DELIVERY_UNKNOWN, current.revoked()); + store.replaceCapability(current, updated); + audit.record(delivered ? "CAPABILITY_DELIVERED" : "CAPABILITY_DELIVERY_UNKNOWN", + current.issuerPrincipalId(), Optional.of(current.objectId()), Map.of("delivered", Boolean.toString(delivered))); + return updated; + } + private boolean validCapability(PkiId objectId, byte[] token) { if (token == null || token.length != 32) return false; for (Capability capability : store.capabilitiesFor(objectId)) { diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java index eeafc2f..87001b4 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -41,8 +41,11 @@ import java.util.Objects; import zeroecho.pki.application.PkiOperation; /** Immutable security descriptors keyed by existing typed-operation identities. */ -@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" }) +@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass", + "PMD.AvoidDuplicateLiterals" }) public final class OperationSecurityDescriptors { + /** Internally fixed execution family; callers cannot select it. */ + public enum Family { PKI_OPERATION, SERVER_CONTROL_OPERATION } /** Approval treatment for one operation. */ public enum ApprovalCategory { NONE, HIGH_RISK } @@ -60,18 +63,27 @@ public final class OperationSecurityDescriptors { * @param approvalCategory approval treatment * @param eligibility future transport eligibility metadata */ - public record Descriptor(String operationId, Permission.Action action, Permission.ResourceType resourceType, + public record Descriptor(String operationId, Family family, Permission.Action action, Permission.ResourceType resourceType, boolean mutating, Permission.DataView dataView, ApprovalCategory approvalCategory, Eligibility eligibility) { /** Validates one finite descriptor. */ public Descriptor { Permission.requireBounded(operationId, 256, "operation ID"); + Objects.requireNonNull(family, "family"); Objects.requireNonNull(action, "action"); Objects.requireNonNull(resourceType, "resourceType"); Objects.requireNonNull(dataView, "dataView"); Objects.requireNonNull(approvalCategory, "approvalCategory"); Objects.requireNonNull(eligibility, "eligibility"); } + + /** Creates an existing PKI-family descriptor for embedded callers. */ + public Descriptor(String operationId, Permission.Action action, Permission.ResourceType resourceType, + boolean mutating, Permission.DataView dataView, ApprovalCategory approvalCategory, + Eligibility eligibility) { + this(operationId, Family.PKI_OPERATION, action, resourceType, mutating, dataView, + approvalCategory, eligibility); + } } private final Map descriptors; @@ -94,7 +106,41 @@ public final class OperationSecurityDescriptors { read(PkiOperation.InspectPublication.NAME, Permission.Action.PUBLICATION_READ, Permission.ResourceType.PUBLICATION), mutate(PkiOperation.ProcessPublication.NAME, Permission.Action.PUBLICATION_PROCESS, Permission.ResourceType.PUBLICATION, false), read(PkiOperation.ListAlgorithmBindings.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING), - read(PkiOperation.InspectAlgorithmBinding.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING))); + read(PkiOperation.InspectAlgorithmBinding.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING), + control(ServerControlOperation.RegisterPrincipal.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, false), + control(ServerControlOperation.InspectPrincipal.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, false, false), + control(ServerControlOperation.ListPrincipals.NAME, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, false, false), + control(ServerControlOperation.SetPrincipalEnabled.ENABLE, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, false), + control(ServerControlOperation.SetPrincipalEnabled.DISABLE, Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, true, true), + control(ServerControlOperation.ListRoleTemplates.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false), + control(ServerControlOperation.InspectRoleTemplate.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false), + control(ServerControlOperation.CreateRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, true, true), + control(ServerControlOperation.InspectRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false), + control(ServerControlOperation.ListRoleAssignments.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, false, false), + control(ServerControlOperation.RevokeRoleAssignment.NAME, Permission.Action.ROLE_MANAGE, Permission.ResourceType.ROLE, true, false), + control(ServerControlOperation.CreateGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, true, true), + control(ServerControlOperation.InspectGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false), + control(ServerControlOperation.ListGrants.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false), + control(ServerControlOperation.RevokeGrant.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, true, false), + control(ServerControlOperation.EvaluateAuthorization.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.GRANT, false, false), + control(ServerControlOperation.RequestApproval.NAME, Permission.Action.REQUEST_SUBMIT, Permission.ResourceType.APPROVAL, true, false), + control(ServerControlOperation.InspectApproval.NAME, Permission.Action.REQUEST_READ_ANY, Permission.ResourceType.APPROVAL, false, false), + control(ServerControlOperation.ListApprovals.NAME, Permission.Action.REQUEST_READ_ANY, Permission.ResourceType.APPROVAL, false, false), + control(ServerControlOperation.DecideApproval.APPROVE, Permission.Action.REQUEST_APPROVE, Permission.ResourceType.APPROVAL, true, false), + control(ServerControlOperation.DecideApproval.REJECT, Permission.Action.REQUEST_REJECT, Permission.ResourceType.APPROVAL, true, false), + control(ServerControlOperation.CancelApproval.NAME, Permission.Action.REQUEST_CANCEL, Permission.ResourceType.APPROVAL, true, false), + control(ServerControlOperation.CreateBreakGlass.CREATE, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, true), + control(ServerControlOperation.ActivateBreakGlass.ACTIVATE, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, true), + control(ServerControlOperation.InspectBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, false, false), + control(ServerControlOperation.ListBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, false, false), + control(ServerControlOperation.RevokeBreakGlass.NAME, Permission.Action.PERMISSION_GRANT, Permission.ResourceType.BREAK_GLASS, true, false), + control(ServerControlOperation.InspectDisclosure.NAME, Permission.Action.DISCLOSURE_READ, Permission.ResourceType.DISCLOSURE, false, false), + control(ServerControlOperation.SetDisclosure.NAME, Permission.Action.DISCLOSURE_CHANGE, Permission.ResourceType.DISCLOSURE, true, true), + control(ServerControlOperation.IssueCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_ISSUE, Permission.ResourceType.CAPABILITY, true, false), + control(ServerControlOperation.RevokeCapability.NAME, Permission.Action.DISCLOSURE_CAPABILITY_REVOKE, Permission.ResourceType.CAPABILITY, true, false), + control(ServerControlOperation.InspectAuditView.REDACTED, Permission.Action.AUDIT_READ_REDACTED, Permission.ResourceType.AUDIT, false, false), + control(ServerControlOperation.InspectAuditView.FULL, Permission.Action.AUDIT_READ_FULL, Permission.ResourceType.AUDIT, false, false), + control(ServerControlOperation.InspectAuditView.PII, Permission.Action.AUDIT_READ_PII, Permission.ResourceType.AUDIT, false, false))); } /** Creates a registry and rejects duplicate operation identities. */ @@ -117,11 +163,30 @@ public final class OperationSecurityDescriptors { return descriptor; } + /** Resolves either operation family through the sole descriptor authority. */ + public Descriptor require(AdministrativeOperation operation) { + Objects.requireNonNull(operation, "operation"); + Descriptor descriptor = descriptors.get(operation.name()); + if (descriptor == null) throw new SecurityException("Operation is not exposed by the server gateway"); + Family expected = operation instanceof AdministrativeOperation.Pki ? Family.PKI_OPERATION + : Family.SERVER_CONTROL_OPERATION; + if (descriptor.family() != expected) throw new SecurityException("Operation family differs"); + return descriptor; + } + /** @return immutable descriptor map keyed by operation ID */ public Map descriptors() { return descriptors; } + /** Resolves one descriptor by stable ID for strict transport decoding. */ + public Descriptor require(String operationId) { + Permission.requireBounded(operationId, 256, "operation ID"); + Descriptor descriptor = descriptors.get(operationId); + if (descriptor == null) throw new SecurityException("Operation is not exposed by the server gateway"); + return descriptor; + } + /** Computes an exact deterministic safe operation commitment. */ public String commitment(RealmId realmId, PkiOperation operation, Permission.Resource resource) { Descriptor descriptor = require(operation); @@ -129,6 +194,15 @@ public final class OperationSecurityDescriptors { + canonicalArguments(operation) + '|' + canonicalResource(resource)); } + /** Computes an exact deterministic commitment for either closed operation family. */ + public String commitment(RealmId realmId, AdministrativeOperation operation, Permission.Resource resource) { + require(operation); + String arguments = operation instanceof AdministrativeOperation.Pki pki + ? canonicalArguments(pki.operation()) : canonicalControl(((AdministrativeOperation.Control) operation).operation()); + return ApprovalService.digest(realmId.value() + '|' + operation.name() + '|' + arguments + '|' + + canonicalResource(resource)); + } + /** Verifies that caller-supplied security scope matches operation identities. */ public void validateResource(PkiOperation operation, Permission.Resource resource) { Descriptor descriptor = require(operation); @@ -214,19 +288,78 @@ public final class OperationSecurityDescriptors { } private static Descriptor read(String id, Permission.Action action, Permission.ResourceType type) { - return new Descriptor(id, action, type, false, Permission.DataView.METADATA_REDACTED, + return new Descriptor(id, Family.PKI_OPERATION, action, type, false, Permission.DataView.METADATA_REDACTED, ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE); } private static Descriptor mutate(String id, Permission.Action action, Permission.ResourceType type, boolean highRisk) { - return new Descriptor(id, action, type, true, Permission.DataView.METADATA_REDACTED, + return new Descriptor(id, Family.PKI_OPERATION, action, type, true, Permission.DataView.METADATA_REDACTED, highRisk ? ApprovalCategory.HIGH_RISK : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE); } private static void validateOperationType(PkiOperation operation, Descriptor descriptor) { - if (!operation.name().equals(descriptor.operationId())) { + if (descriptor.family() != Family.PKI_OPERATION || !operation.name().equals(descriptor.operationId())) { throw new SecurityException("Operation identity and descriptor differ"); } } + + private static Descriptor control(String id, Permission.Action action, Permission.ResourceType type, + boolean mutating, boolean highRisk) { + Permission.DataView view = action == Permission.Action.AUDIT_READ_PII + ? Permission.DataView.PII_FULL : action == Permission.Action.AUDIT_READ_FULL + ? Permission.DataView.METADATA_FULL : Permission.DataView.METADATA_REDACTED; + return new Descriptor(id, Family.SERVER_CONTROL_OPERATION, action, type, mutating, + view, highRisk ? ApprovalCategory.HIGH_RISK + : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE); + } + + private static String canonicalControl(ServerControlOperation operation) { + return switch (operation) { + case ServerControlOperation.RegisterPrincipal value -> "principal=" + atom(value.principal().principalId()); + case ServerControlOperation.InspectPrincipal value -> "principal=" + atom(value.principalId()); + case ServerControlOperation.ListPrincipals value -> "offset=" + value.offset() + ";limit=" + value.limit(); + case ServerControlOperation.SetPrincipalEnabled value -> "principal=" + atom(value.principalId()) + + ";enabled=" + value.enabled(); + case ServerControlOperation.ListRoleTemplates ignored -> "all"; + case ServerControlOperation.InspectRoleTemplate value -> "template=" + atom(value.templateId()) + + ";version=" + value.version(); + case ServerControlOperation.CreateRoleAssignment value -> "assignment=" + + atom(value.assignment().assignmentId()) + ";principal=" + atom(value.assignment().principalId()); + case ServerControlOperation.InspectRoleAssignment value -> "assignment=" + atom(value.assignmentId()); + case ServerControlOperation.ListRoleAssignments value -> "offset=" + value.offset() + ";limit=" + value.limit(); + case ServerControlOperation.RevokeRoleAssignment value -> "assignment=" + atom(value.assignmentId()); + case ServerControlOperation.CreateGrant value -> "grant=" + atom(value.grant().grantId()) + + ";principal=" + atom(value.grant().principalId()) + ";action=" + value.grant().action().name(); + case ServerControlOperation.InspectGrant value -> "grant=" + atom(value.grantId()); + case ServerControlOperation.ListGrants value -> "offset=" + value.offset() + ";limit=" + value.limit(); + case ServerControlOperation.RevokeGrant value -> "grant=" + atom(value.grantId()); + case ServerControlOperation.EvaluateAuthorization value -> "principal=" + atom(value.principalId()) + + ";action=" + value.action().name(); + case ServerControlOperation.RequestApproval value -> "approval=" + atom(value.approvalId()) + + ";target=" + atom(value.targetOperation().name()); + case ServerControlOperation.InspectApproval value -> "approval=" + atom(value.approvalId()); + case ServerControlOperation.ListApprovals value -> "offset=" + value.offset() + ";limit=" + value.limit() + + ";state=" + atom(value.state().map(Enum::name).orElse("")) + ";requester=" + + atom(value.requesterPrincipalId().orElse("")) + ";target=" + + atom(value.targetOperationId().orElse("")); + case ServerControlOperation.DecideApproval value -> "approval=" + atom(value.approvalId()) + + ";choice=" + value.choice().name(); + case ServerControlOperation.CancelApproval value -> "approval=" + atom(value.approvalId()); + case ServerControlOperation.CreateBreakGlass value -> "breakGlass=" + atom(value.breakGlassId()) + + ";principal=" + atom(value.principalId()) + ";expiry=" + value.expiresAt(); + case ServerControlOperation.ActivateBreakGlass value -> "breakGlass=" + atom(value.breakGlassId()); + case ServerControlOperation.InspectBreakGlass value -> "breakGlass=" + atom(value.breakGlassId()); + case ServerControlOperation.ListBreakGlass value -> "offset=" + value.offset() + ";limit=" + value.limit(); + case ServerControlOperation.RevokeBreakGlass value -> "breakGlass=" + atom(value.breakGlassId()); + case ServerControlOperation.InspectDisclosure value -> "object=" + atom(value.objectId().value()); + case ServerControlOperation.SetDisclosure value -> "object=" + atom(value.objectId().value()) + + ";policy=" + value.policy().name(); + case ServerControlOperation.IssueCapability value -> "object=" + atom(value.objectId().value()) + + ";expiry=" + value.expiresAt(); + case ServerControlOperation.RevokeCapability value -> "capability=" + atom(value.capabilityId()); + case ServerControlOperation.InspectAuditView value -> "object=" + atom(value.objectId().value()) + + ";view=" + value.view().name(); + }; + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java index 0b18f0a..04a3d28 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/Permission.java +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -67,7 +67,9 @@ public final class Permission { OCSP_ADMINISTER(87), PUBLICATION_REGISTER(100), PUBLICATION_READ(101), PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104), AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123), - AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142); + AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142), + DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162), + DISCLOSURE_CAPABILITY_REVOKE(163); private final int code; @@ -109,7 +111,7 @@ public final class Permission { REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10), ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21), REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32), - DISCLOSURE(33), CAPABILITY(34); + DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36); private final int code; ResourceType(int code) { this.code = code; } /** @return stable code */ public int code() { return code; } diff --git a/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java index 63b8a15..875af1e 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java +++ b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java @@ -70,7 +70,8 @@ public final class RoleTemplateCatalog { /** Persistable scoped role assignment; the template itself grants nothing. */ public record Assignment(String assignmentId, String principalId, String templateId, - int templateVersion, Permission.Scope scope, boolean enabled) { + int templateVersion, Permission.Scope scope, java.util.Optional expiresAt, + boolean enabled) { /** Validates the exact scoped assignment. */ public Assignment { Permission.requireId(assignmentId, "assignment"); @@ -78,6 +79,13 @@ public final class RoleTemplateCatalog { Permission.requireId(templateId, "role template"); if (templateVersion <= 0) throw new IllegalArgumentException("Template version must be positive"); Objects.requireNonNull(scope, "scope"); + expiresAt = Objects.requireNonNull(expiresAt, "expiresAt"); + } + + /** Creates an assignment without an expiry for existing embedded callers. */ + public Assignment(String assignmentId, String principalId, String templateId, + int templateVersion, Permission.Scope scope, boolean enabled) { + this(assignmentId, principalId, templateId, templateVersion, scope, java.util.Optional.empty(), enabled); } } @@ -169,7 +177,7 @@ public final class RoleTemplateCatalog { return template.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code)) .map(action -> new Permission.Grant(exact.assignmentId() + ":" + action.code(), exact.principalId(), Permission.Effect.ALLOW, action, resourceType(action), exact.scope(), - relationship(template, action), dataView(action), Set.of(), java.util.Optional.empty(), true)) + relationship(template, action), dataView(action), Set.of(), exact.expiresAt(), true)) .toList(); } @@ -188,6 +196,8 @@ public final class RoleTemplateCatalog { if (name.startsWith("AUDIT")) return Permission.ResourceType.AUDIT; if (name.startsWith("BACKUP")) return Permission.ResourceType.BACKUP; if (name.startsWith("RESTORE")) return Permission.ResourceType.RESTORE; + if (name.startsWith("DISCLOSURE_CAPABILITY")) return Permission.ResourceType.CAPABILITY; + if (name.startsWith("DISCLOSURE")) return Permission.ResourceType.DISCLOSURE; if (name.startsWith("PRINCIPAL")) return Permission.ResourceType.PRINCIPAL; if (name.startsWith("ROLE")) return Permission.ResourceType.ROLE; if (name.startsWith("PERMISSION")) return Permission.ResourceType.GRANT; diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java new file mode 100644 index 0000000..115430d --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperation.java @@ -0,0 +1,280 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.pki.api.PkiId; + +/** Closed transport-neutral server-control administration operation hierarchy. */ +@SuppressWarnings("PMD.ControlStatementBraces") +public sealed interface ServerControlOperation permits ServerControlOperation.RegisterPrincipal, + ServerControlOperation.InspectPrincipal, ServerControlOperation.ListPrincipals, + ServerControlOperation.SetPrincipalEnabled, ServerControlOperation.ListRoleTemplates, + ServerControlOperation.InspectRoleTemplate, ServerControlOperation.CreateRoleAssignment, + ServerControlOperation.InspectRoleAssignment, ServerControlOperation.ListRoleAssignments, + ServerControlOperation.RevokeRoleAssignment, ServerControlOperation.CreateGrant, + ServerControlOperation.InspectGrant, ServerControlOperation.ListGrants, + ServerControlOperation.RevokeGrant, ServerControlOperation.EvaluateAuthorization, + ServerControlOperation.RequestApproval, ServerControlOperation.InspectApproval, + ServerControlOperation.ListApprovals, ServerControlOperation.DecideApproval, + ServerControlOperation.CancelApproval, ServerControlOperation.CreateBreakGlass, + ServerControlOperation.ActivateBreakGlass, + ServerControlOperation.InspectBreakGlass, ServerControlOperation.ListBreakGlass, + ServerControlOperation.RevokeBreakGlass, ServerControlOperation.InspectDisclosure, + ServerControlOperation.SetDisclosure, ServerControlOperation.IssueCapability, + ServerControlOperation.RevokeCapability, ServerControlOperation.InspectAuditView { + + /** @return stable operation identity */ + String name(); + + /** Principal registration. */ + record RegisterPrincipal(SecurityPrincipal principal) implements ServerControlOperation { + public static final String NAME = "security.principal.register"; + public RegisterPrincipal { Objects.requireNonNull(principal, "principal"); } + @Override public String name() { return NAME; } + } + /** Principal lookup. */ + record InspectPrincipal(String principalId) implements ServerControlOperation { + public static final String NAME = "security.principal.inspect"; + public InspectPrincipal { Permission.requirePrincipal(principalId); } + @Override public String name() { return NAME; } + } + /** Bounded principal page. */ + record ListPrincipals(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "security.principal.list"; + public ListPrincipals { page(offset, limit); } + @Override public String name() { return NAME; } + } + /** Principal enablement transition. */ + record SetPrincipalEnabled(String principalId, boolean enabled) implements ServerControlOperation { + public static final String ENABLE = "security.principal.enable"; + public static final String DISABLE = "security.principal.disable"; + public SetPrincipalEnabled { Permission.requirePrincipal(principalId); } + @Override public String name() { return enabled ? ENABLE : DISABLE; } + } + /** Immutable role-template list. */ + record ListRoleTemplates() implements ServerControlOperation { + public static final String NAME = "security.role-template.list"; + @Override public String name() { return NAME; } + } + /** Immutable role-template lookup. */ + record InspectRoleTemplate(String templateId, int version) implements ServerControlOperation { + public static final String NAME = "security.role-template.inspect"; + public InspectRoleTemplate { Permission.requireId(templateId, "role template"); if (version <= 0) throw invalid(); } + @Override public String name() { return NAME; } + } + /** Scoped assignment creation. */ + record CreateRoleAssignment(RoleTemplateCatalog.Assignment assignment) implements ServerControlOperation { + public static final String NAME = "security.role-assignment.create"; + public CreateRoleAssignment { Objects.requireNonNull(assignment, "assignment"); } + @Override public String name() { return NAME; } + } + /** Scoped assignment lookup. */ + record InspectRoleAssignment(String assignmentId) implements ServerControlOperation { + public static final String NAME = "security.role-assignment.inspect"; + public InspectRoleAssignment { Permission.requireId(assignmentId, "assignment"); } + @Override public String name() { return NAME; } + } + /** Bounded assignment page. */ + record ListRoleAssignments(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "security.role-assignment.list"; + public ListRoleAssignments { page(offset, limit); } + @Override public String name() { return NAME; } + } + /** Durable assignment revocation. */ + record RevokeRoleAssignment(String assignmentId) implements ServerControlOperation { + public static final String NAME = "security.role-assignment.revoke"; + public RevokeRoleAssignment { Permission.requireId(assignmentId, "assignment"); } + @Override public String name() { return NAME; } + } + /** Direct grant creation. */ + record CreateGrant(Permission.Grant grant) implements ServerControlOperation { + public static final String NAME = "security.grant.create"; + public CreateGrant { Objects.requireNonNull(grant, "grant"); } + @Override public String name() { return NAME; } + } + /** Direct grant lookup. */ + record InspectGrant(String grantId) implements ServerControlOperation { + public static final String NAME = "security.grant.inspect"; + public InspectGrant { Permission.requireId(grantId, "grant"); } + @Override public String name() { return NAME; } + } + /** Bounded direct-grant page. */ + record ListGrants(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "security.grant.list"; + public ListGrants { page(offset, limit); } + @Override public String name() { return NAME; } + } + /** Durable direct-grant revocation. */ + record RevokeGrant(String grantId) implements ServerControlOperation { + public static final String NAME = "security.grant.revoke"; + public RevokeGrant { Permission.requireId(grantId, "grant"); } + @Override public String name() { return NAME; } + } + /** Side-effect-free authorization simulation. */ + record EvaluateAuthorization(String principalId, Permission.Action action, Permission.Resource resource, + Permission.Relationship relationship, Permission.DataView dataView, + Permission.Context context) implements ServerControlOperation { + public static final String NAME = "security.authorization.evaluate"; + public EvaluateAuthorization { Permission.requirePrincipal(principalId); Objects.requireNonNull(action); + Objects.requireNonNull(resource); Objects.requireNonNull(relationship); Objects.requireNonNull(dataView); + Objects.requireNonNull(context); } + @Override public String name() { return NAME; } + } + /** Exact future-operation approval request. */ + record RequestApproval(String approvalId, AdministrativeOperation targetOperation, + Permission.Resource targetResource) implements ServerControlOperation { + public static final String NAME = "security.approval.request"; + public RequestApproval { Permission.requireId(approvalId, "approval"); + Objects.requireNonNull(targetOperation); Objects.requireNonNull(targetResource); + if (targetOperation instanceof AdministrativeOperation.Control control + && control.operation() instanceof RequestApproval) throw invalid(); } + @Override public String name() { return NAME; } + } + /** Approval lookup. */ + record InspectApproval(String approvalId) implements ServerControlOperation { + public static final String NAME = "security.approval.inspect"; + public InspectApproval { Permission.requireId(approvalId, "approval"); } + @Override public String name() { return NAME; } + } + /** Bounded approval page. */ + record ListApprovals(int offset, int limit, Optional state, + Optional requesterPrincipalId, Optional targetOperationId) + implements ServerControlOperation { + public static final String NAME = "security.approval.list"; + public ListApprovals { page(offset, limit); state = Objects.requireNonNull(state); + requesterPrincipalId = Objects.requireNonNull(requesterPrincipalId) + .map(Permission::requirePrincipal); + targetOperationId = Objects.requireNonNull(targetOperationId) + .map(value -> Permission.requireBounded(value, 256, "target operation")); } + /** Creates an unfiltered bounded approval page. */ + public ListApprovals(int offset, int limit) { + this(offset, limit, Optional.empty(), Optional.empty(), Optional.empty()); + } + @Override public String name() { return NAME; } + } + /** Approval or rejection decision. */ + record DecideApproval(String approvalId, ApprovalService.Choice choice, + String justification) implements ServerControlOperation { + public static final String APPROVE = "security.approval.approve"; + public static final String REJECT = "security.approval.reject"; + public DecideApproval { Permission.requireId(approvalId, "approval"); Objects.requireNonNull(choice); + Permission.requireBounded(justification, 2048, "justification"); } + @Override public String name() { return choice == ApprovalService.Choice.APPROVE ? APPROVE : REJECT; } + } + /** Approval cancellation. */ + record CancelApproval(String approvalId) implements ServerControlOperation { + public static final String NAME = "security.approval.cancel"; + public CancelApproval { Permission.requireId(approvalId, "approval"); } + @Override public String name() { return NAME; } + } + /** Independently approved emergency grant creation. */ + record CreateBreakGlass(String breakGlassId, String principalId, Permission.Grant grant, + String reason, Instant expiresAt) implements ServerControlOperation { + public static final String CREATE = "security.break-glass.create"; + public CreateBreakGlass { Permission.requireId(breakGlassId, "break-glass"); + Permission.requirePrincipal(principalId); Objects.requireNonNull(grant); + Permission.requireBounded(reason, 2048, "reason"); Objects.requireNonNull(expiresAt); } + @Override public String name() { return CREATE; } + } + /** Activation of one already-created approved emergency grant. */ + record ActivateBreakGlass(String breakGlassId) implements ServerControlOperation { + public static final String ACTIVATE = "security.break-glass.activate"; + public ActivateBreakGlass { Permission.requireId(breakGlassId, "break-glass"); } + @Override public String name() { return ACTIVATE; } + } + /** Break-glass lookup. */ + record InspectBreakGlass(String breakGlassId) implements ServerControlOperation { + public static final String NAME = "security.break-glass.inspect"; + public InspectBreakGlass { Permission.requireId(breakGlassId, "break-glass"); } + @Override public String name() { return NAME; } + } + /** Bounded break-glass page. */ + record ListBreakGlass(int offset, int limit) implements ServerControlOperation { + public static final String NAME = "security.break-glass.list"; + public ListBreakGlass { page(offset, limit); } + @Override public String name() { return NAME; } + } + /** Break-glass revocation. */ + record RevokeBreakGlass(String breakGlassId) implements ServerControlOperation { + public static final String NAME = "security.break-glass.revoke"; + public RevokeBreakGlass { Permission.requireId(breakGlassId, "break-glass"); } + @Override public String name() { return NAME; } + } + /** Disclosure metadata lookup. */ + record InspectDisclosure(PkiId objectId) implements ServerControlOperation { + public static final String NAME = "security.disclosure.inspect"; + public InspectDisclosure { Objects.requireNonNull(objectId); } + @Override public String name() { return NAME; } + } + /** Disclosure-only state transition. */ + record SetDisclosure(PkiId objectId, DisclosureService.Policy policy) implements ServerControlOperation { + public static final String NAME = "security.disclosure.set"; + public SetDisclosure { Objects.requireNonNull(objectId); Objects.requireNonNull(policy); } + @Override public String name() { return NAME; } + } + /** One-time unlisted retrieval capability issuance. */ + record IssueCapability(PkiId objectId, Instant expiresAt) implements ServerControlOperation { + public static final String NAME = "security.disclosure.capability.issue"; + public IssueCapability { Objects.requireNonNull(objectId); Objects.requireNonNull(expiresAt); } + @Override public String name() { return NAME; } + } + /** Capability revocation by non-bearer record identity. */ + record RevokeCapability(String capabilityId) implements ServerControlOperation { + public static final String NAME = "security.disclosure.capability.revoke"; + public RevokeCapability { Permission.requireId(capabilityId, "capability"); } + @Override public String name() { return NAME; } + } + /** Existing safe auditor projection over one server-control object. */ + record InspectAuditView(PkiId objectId, Permission.DataView view, + Optional reasonReference) implements ServerControlOperation { + public static final String REDACTED = "security.audit-view.inspect-redacted"; + public static final String FULL = "security.audit-view.inspect-full"; + public static final String PII = "security.audit-view.inspect-pii"; + public InspectAuditView { Objects.requireNonNull(objectId); Objects.requireNonNull(view); + reasonReference = Objects.requireNonNull(reasonReference); } + @Override public String name() { return switch (view) { case METADATA_REDACTED -> REDACTED; + case METADATA_FULL, CONTENT_FULL -> FULL; case PII_FULL -> PII; }; } + } + + private static void page(int offset, int limit) { + if (offset < 0 || limit <= 0 || limit > 256) throw invalid(); + } + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("Server-control operation input is invalid"); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java new file mode 100644 index 0000000..bf38eec --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationExecutor.java @@ -0,0 +1,370 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.application.PkiOperationFailure; +import zeroecho.pki.application.PkiOperationOutcome; +import zeroecho.pki.application.PkiOperationResult; +import zeroecho.pki.application.PkiOperationValue; + +/** Sole closed dispatcher for transport-neutral server-control operations. */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.ExcessiveParameterList", + "PMD.ControlStatementBraces" }) +public final class ServerControlOperationExecutor { + private final RealmId realmId; + private final AuthorityExposurePolicy exposure; + private final ServerControlStore store; + private final RoleTemplateCatalog roles; + private final AuthorizationEngine authorization; + private final ApprovalService approvals; + private final BreakGlassService breakGlass; + private final DisclosureService disclosure; + private final AuditorViews auditorViews; + private final OperationSecurityDescriptors descriptors; + private final Map approvalPolicies; + + /** Creates the one control dispatcher over existing durable authorities. */ + public ServerControlOperationExecutor(RealmId realmId, AuthorityExposurePolicy exposure, + ServerControlStore store, RoleTemplateCatalog roles, AuthorizationEngine authorization, + ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure, + AuditorViews auditorViews, + OperationSecurityDescriptors descriptors, + Map approvalPolicies) { + this.realmId = Objects.requireNonNull(realmId, "realmId"); + this.exposure = Objects.requireNonNull(exposure, "exposure"); + this.store = Objects.requireNonNull(store, "store"); + this.roles = Objects.requireNonNull(roles, "roles"); + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.approvals = Objects.requireNonNull(approvals, "approvals"); + this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass"); + this.disclosure = Objects.requireNonNull(disclosure, "disclosure"); + this.auditorViews = Objects.requireNonNull(auditorViews, "auditorViews"); + this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); + this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); + } + + /** Executes exactly one already-authorized control operation synchronously. */ + public ServerControlOperationOutcome execute(ServerControlOperation operation, String actorPrincipalId, + Permission.Resource authorizedResource, Optional approvalId, CancellationSignal cancellation) { + Objects.requireNonNull(operation, "operation"); + Permission.requirePrincipal(actorPrincipalId); + Objects.requireNonNull(authorizedResource, "authorizedResource"); + if (Objects.requireNonNull(cancellation, "cancellation").isCancelled()) { + return failure(operation, PkiOperationFailure.CANCELLED, "CONTROL_OPERATION_CANCELLED"); + } + try { + return dispatch(operation, actorPrincipalId, authorizedResource, + Objects.requireNonNull(approvalId, "approvalId")); + } catch (IllegalArgumentException unavailable) { + return failure(operation, PkiOperationFailure.NOT_FOUND, "CONTROL_OBJECT_UNAVAILABLE"); + } catch (SecurityException denied) { + return failure(operation, PkiOperationFailure.POLICY_REJECTION, "CONTROL_POLICY_REJECTED"); + } catch (IllegalStateException conflict) { + return failure(operation, PkiOperationFailure.CONFLICT, "CONTROL_STATE_CONFLICT"); + } + } + + private ServerControlOperationOutcome dispatch(ServerControlOperation operation, String actor, + Permission.Resource authorizedResource, Optional approvalId) { + return switch (operation) { + case ServerControlOperation.RegisterPrincipal value -> ordinary(operation, register(value)); + case ServerControlOperation.InspectPrincipal value -> ordinary(operation, + principal(store.requirePrincipal(value.principalId()))); + case ServerControlOperation.ListPrincipals value -> ordinary(operation, + page(store.principals(value.offset(), value.limit()), ServerControlOperationExecutor::principal)); + case ServerControlOperation.SetPrincipalEnabled value -> ordinary(operation, + principal(changePrincipalEnabled(value))); + case ServerControlOperation.ListRoleTemplates ignored -> ordinary(operation, templatesValue()); + case ServerControlOperation.InspectRoleTemplate value -> ordinary(operation, + template(roles.require(value.templateId(), value.version()))); + case ServerControlOperation.CreateRoleAssignment value -> ordinary(operation, + assignment(createAssignment(value.assignment()))); + case ServerControlOperation.InspectRoleAssignment value -> ordinary(operation, + assignment(store.requireAssignment(value.assignmentId()))); + case ServerControlOperation.ListRoleAssignments value -> ordinary(operation, + scopedPage(store.assignments(value.offset(), value.limit()), authorizedResource.scope(), + RoleTemplateCatalog.Assignment::scope, ServerControlOperationExecutor::assignment)); + case ServerControlOperation.RevokeRoleAssignment value -> ordinary(operation, + assignment(revokeAssignment(value.assignmentId()))); + case ServerControlOperation.CreateGrant value -> ordinary(operation, + grant(createGrant(value.grant()))); + case ServerControlOperation.InspectGrant value -> ordinary(operation, + grant(store.requireGrant(value.grantId()))); + case ServerControlOperation.ListGrants value -> ordinary(operation, + scopedPage(store.grants(value.offset(), value.limit()), authorizedResource.scope(), + Permission.Grant::scope, ServerControlOperationExecutor::grant)); + case ServerControlOperation.RevokeGrant value -> ordinary(operation, grant(revokeGrant(value.grantId()))); + case ServerControlOperation.EvaluateAuthorization value -> ordinary(operation, evaluate(value)); + case ServerControlOperation.RequestApproval value -> ordinary(operation, approval(request(value, actor))); + case ServerControlOperation.InspectApproval value -> ordinary(operation, + approval(approvals.requireCurrent(value.approvalId()))); + case ServerControlOperation.ListApprovals value -> ordinary(operation, + approvalPage(store.approvals(value.offset(), value.limit()), authorizedResource.scope(), value)); + case ServerControlOperation.DecideApproval value -> ordinary(operation, + approval(approvals.decide(value.approvalId(), actor, value.choice(), value.justification()))); + case ServerControlOperation.CancelApproval value -> ordinary(operation, + approval(approvals.cancel(value.approvalId(), actor))); + case ServerControlOperation.CreateBreakGlass value -> ordinary(operation, + breakGlass(breakGlass.create(value.breakGlassId(), value.principalId(), value.grant(), + value.reason(), actor, approvalId, value.expiresAt()))); + case ServerControlOperation.ActivateBreakGlass value -> ordinary(operation, + breakGlass(breakGlass.activatePending(value.breakGlassId(), actor, approvalId))); + case ServerControlOperation.InspectBreakGlass value -> ordinary(operation, + breakGlass(breakGlass.requireCurrent(value.breakGlassId()))); + case ServerControlOperation.ListBreakGlass value -> ordinary(operation, + scopedPage(store.breakGlass(value.offset(), value.limit()), authorizedResource.scope(), + item -> item.grant().scope(), ServerControlOperationExecutor::breakGlass)); + case ServerControlOperation.RevokeBreakGlass value -> ordinary(operation, + breakGlass(breakGlass.revoke(value.breakGlassId(), actor))); + case ServerControlOperation.InspectDisclosure value -> ordinary(operation, + disclosure(store.requireDisclosure(value.objectId()))); + case ServerControlOperation.SetDisclosure value -> ordinary(operation, + disclosure(disclosure.change(value.objectId(), value.policy(), true, approvalId.isPresent(), actor))); + case ServerControlOperation.IssueCapability value -> capability(operation, + disclosure.issueCapability(value.objectId(), value.expiresAt(), actor)); + case ServerControlOperation.RevokeCapability value -> ordinary(operation, + capability(disclosure.revokeCapability(value.capabilityId(), actor))); + case ServerControlOperation.InspectAuditView value -> ordinary(operation, auditView(value, actor)); + }; + } + + private PkiOperationValue register(ServerControlOperation.RegisterPrincipal operation) { + try { store.createPrincipal(operation.principal()); } + catch (IllegalStateException existing) { + if (!store.requirePrincipal(operation.principal().principalId()).equals(operation.principal())) throw existing; + } + return principal(operation.principal()); + } + + private SecurityPrincipal changePrincipalEnabled(ServerControlOperation.SetPrincipalEnabled operation) { + SecurityPrincipal current = store.requirePrincipal(operation.principalId()); + SecurityPrincipal updated = new SecurityPrincipal(current.principalId(), current.type(), + current.displayName(), current.organization(), current.attributes(), operation.enabled()); + store.replacePrincipal(current, updated); + return updated; + } + + private RoleTemplateCatalog.Assignment createAssignment(RoleTemplateCatalog.Assignment value) { + if (!value.scope().realmId().equals(realmId)) throw new SecurityException("Assignment realm differs"); + roles.instantiate(value); store.requirePrincipal(value.principalId()); store.createAssignment(value); return value; + } + + private RoleTemplateCatalog.Assignment revokeAssignment(String id) { + RoleTemplateCatalog.Assignment current = store.requireAssignment(id); + RoleTemplateCatalog.Assignment revoked = new RoleTemplateCatalog.Assignment(current.assignmentId(), + current.principalId(), current.templateId(), current.templateVersion(), current.scope(), + current.expiresAt(), false); + store.replaceAssignment(current, revoked); return revoked; + } + + private Permission.Grant createGrant(Permission.Grant value) { + if (!value.scope().realmId().equals(realmId)) throw new SecurityException("Grant realm differs"); + store.requirePrincipal(value.principalId()); store.createGrant(value); return value; + } + + private Permission.Grant revokeGrant(String id) { + Permission.Grant current = store.requireGrant(id); + Permission.Grant revoked = new Permission.Grant(current.grantId(), current.principalId(), current.effect(), + current.action(), current.resourceType(), current.scope(), current.relationship(), current.dataView(), + current.conditions(), current.expiresAt(), false); + store.replaceGrant(current, revoked); return revoked; + } + + private PkiOperationValue evaluate(ServerControlOperation.EvaluateAuthorization value) { + SecurityPrincipal principal = store.requirePrincipal(value.principalId()); + List grants = grants(principal.principalId()); + BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId()); + grants.addAll(emergency.grants()); + AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId, + exposure, principal, value.action(), value.resource(), value.relationship(), value.dataView(), + value.context(), grants, emergency.grantIds())); + return object("allowed", bool(decision.allowed()), "code", text(decision.code().name())); + } + + private ApprovalService.Request request(ServerControlOperation.RequestApproval value, String actor) { + OperationSecurityDescriptors.Descriptor descriptor = descriptors.require(value.targetOperation()); + if (!descriptor.mutating() || descriptor.approvalCategory() == OperationSecurityDescriptors.ApprovalCategory.NONE) { + throw new IllegalStateException("Target operation does not require approval"); + } + ApprovalService.Policy policy = approvalPolicies.get(descriptor.approvalCategory()); + if (policy == null) throw new IllegalStateException("Approval policy is unavailable"); + String commitment = descriptors.commitment(realmId, value.targetOperation(), value.targetResource()); + return approvals.request(value.approvalId(), value.targetOperation().name(), commitment, + value.targetResource().scope(), actor, policy); + } + + private List grants(String principalId) { + List result = new ArrayList<>(store.grantsFor(principalId)); + for (RoleTemplateCatalog.Assignment assignment : store.assignmentsFor(principalId)) { + result.addAll(roles.instantiate(assignment)); + } + return result; + } + + private PkiOperationValue auditView(ServerControlOperation.InspectAuditView value, String actor) { + DisclosureService.Record record = store.requireDisclosure(value.objectId()); + AuditorViews.Source source = new AuditorViews.Source(record.objectId(), Permission.ResourceType.DISCLOSURE, + new Permission.Scope(realmId, Optional.empty(), Optional.empty(), Optional.empty()), + record.policy().name(), record.updatedAt(), "NOT_APPLICABLE", Optional.empty(), + record.policyCommitment(), false, 0, Optional.empty(), List.of(), Optional.empty()); + Map fields = new LinkedHashMap<>(); + fields.put("objectId", text(record.objectId().value())); fields.put("objectType", text(record.objectType().name())); + fields.put("policy", text(record.policy().name())); fields.put("updatedAt", text(record.updatedAt().toString())); + if (value.view() == Permission.DataView.PII_FULL) { + String reason = value.reasonReference().orElseThrow(() -> new SecurityException("PII reason is required")); + auditorViews.pii(source, store.requirePrincipal(actor), reason, true, true); + } else if (value.view() == Permission.DataView.METADATA_FULL) { + auditorViews.fullMetadata(source, true); + } else { + auditorViews.redacted(source); + } + fields.put("view", text(value.view().name())); return new PkiOperationValue.ObjectValue(fields); + } + + private static ServerControlOperationOutcome ordinary(ServerControlOperation operation, + PkiOperationValue value) { + return new ServerControlOperationOutcome.Ordinary(new PkiOperationOutcome.Success( + new PkiOperationResult(operation.name(), Map.of("value", value)))); + } + + private static ServerControlOperationOutcome capability(ServerControlOperation operation, + DisclosureService.IssuedCapability issued) { + PkiOperationResult safe = new PkiOperationResult(operation.name(), Map.of( + "capabilityId", text(issued.capability().capabilityId()), + "objectId", text(issued.capability().objectId().value()), + "expiresAt", text(issued.capability().expiresAt().toString()))); + byte[] token = issued.token(); + try { + return new ServerControlOperationOutcome.OneTimeSensitive(safe, token); + } finally { + java.util.Arrays.fill(token, (byte) 0); + } + } + + private static ServerControlOperationOutcome failure(ServerControlOperation operation, + PkiOperationFailure failure, String code) { + return new ServerControlOperationOutcome.Ordinary(new PkiOperationOutcome.Failure( + operation.name(), failure, code)); + } + + private static PkiOperationValue principal(SecurityPrincipal value) { + return object("principalId", text(value.principalId()), "type", text(value.type().name()), + "displayName", text(value.displayName()), "enabled", bool(value.enabled())); + } + private static PkiOperationValue template(RoleTemplateCatalog.Template value) { + List actions = value.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code)) + .map(item -> (PkiOperationValue) text(item.name())).toList(); + return object("templateId", text(value.templateId()), "version", integer(value.version()), + "actions", new PkiOperationValue.ListValue(actions)); + } + private PkiOperationValue templatesValue() { + return new PkiOperationValue.ListValue(roles.templates().stream() + .map(ServerControlOperationExecutor::template).toList()); + } + private static PkiOperationValue assignment(RoleTemplateCatalog.Assignment value) { + return object("assignmentId", text(value.assignmentId()), "principalId", text(value.principalId()), + "templateId", text(value.templateId()), "enabled", bool(value.enabled())); + } + private static PkiOperationValue grant(Permission.Grant value) { + return object("grantId", text(value.grantId()), "principalId", text(value.principalId()), + "effect", text(value.effect().name()), "action", text(value.action().name()), + "enabled", bool(value.enabled())); + } + private static PkiOperationValue approval(ApprovalService.Request value) { + return object("approvalId", text(value.approvalId()), "operation", text(value.operationId()), + "requesterId", text(value.requesterPrincipalId()), "state", text(value.state().name()), + "expiresAt", text(value.expiresAt().toString())); + } + private static PkiOperationValue breakGlass(BreakGlassService.Record value) { + return object("breakGlassId", text(value.breakGlassId()), "principalId", text(value.principalId()), + "state", text(value.state().name()), "expiresAt", text(value.expiresAt().toString())); + } + private static PkiOperationValue disclosure(DisclosureService.Record value) { + return object("objectId", text(value.objectId().value()), "objectType", text(value.objectType().name()), + "policy", text(value.policy().name()), "policyCommitment", text(value.policyCommitment())); + } + private static PkiOperationValue capability(DisclosureService.Capability value) { + return object("capabilityId", text(value.capabilityId()), "objectId", text(value.objectId().value()), + "expiresAt", text(value.expiresAt().toString()), "revoked", bool(value.revoked())); + } + private static PkiOperationValue page(ServerControlStore.Page page, + java.util.function.Function renderer) { + return object("items", new PkiOperationValue.ListValue(page.values().stream().map(renderer).toList()), + "nextOffset", integer(page.nextOffset()), "hasMore", bool(page.hasMore())); + } + private static PkiOperationValue scopedPage(ServerControlStore.Page page, + Permission.Scope requested, java.util.function.Function scope, + java.util.function.Function renderer) { + List values = page.values().stream() + .filter(item -> contains(requested, scope.apply(item))).map(renderer).toList(); + return object("items", new PkiOperationValue.ListValue(values), "nextOffset", + integer(page.nextOffset()), "hasMore", bool(page.hasMore())); + } + private static PkiOperationValue approvalPage(ServerControlStore.Page page, + Permission.Scope requested, ServerControlOperation.ListApprovals filter) { + List values = page.values().stream() + .filter(item -> contains(requested, item.scope())) + .filter(item -> filter.state().map(item.state()::equals).orElse(true)) + .filter(item -> filter.requesterPrincipalId().map(item.requesterPrincipalId()::equals).orElse(true)) + .filter(item -> filter.targetOperationId().map(item.operationId()::equals).orElse(true)) + .map(ServerControlOperationExecutor::approval).toList(); + return object("items", new PkiOperationValue.ListValue(values), "nextOffset", + integer(page.nextOffset()), "hasMore", bool(page.hasMore())); + } + private static boolean contains(Permission.Scope requested, Permission.Scope actual) { + return requested.realmId().equals(actual.realmId()) + && requested.authorityId().map(value -> actual.authorityId().filter(value::equals).isPresent()).orElse(true) + && requested.issuerId().map(value -> actual.issuerId().filter(value::equals).isPresent()).orElse(true) + && requested.profileId().map(value -> actual.profileId().filter(value::equals).isPresent()).orElse(true); + } + private static PkiOperationValue.ObjectValue object(Object... entries) { + Map fields = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + fields.put((String) entries[index], (PkiOperationValue) entries[index + 1]); + } + return new PkiOperationValue.ObjectValue(fields); + } + private static PkiOperationValue.Text text(String value) { return new PkiOperationValue.Text(value); } + private static PkiOperationValue.BooleanValue bool(boolean value) { return new PkiOperationValue.BooleanValue(value); } + private static PkiOperationValue.IntegerValue integer(long value) { return new PkiOperationValue.IntegerValue(value); } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java new file mode 100644 index 0000000..27ecce5 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlOperationOutcome.java @@ -0,0 +1,80 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import zeroecho.pki.application.PkiOperationOutcome; +import zeroecho.pki.application.PkiOperationResult; + +/** Closed server-control result with explicit one-time-sensitive handling. */ +@SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", "PMD.ControlStatementBraces" }) +public sealed interface ServerControlOperationOutcome permits ServerControlOperationOutcome.Ordinary, + ServerControlOperationOutcome.OneTimeSensitive { + /** Response handling classification. */ + enum Classification { ORDINARY, ONE_TIME_SENSITIVE } + /** @return response handling classification */ + Classification classification(); + + /** Ordinary safe replayable operation result. */ + record Ordinary(PkiOperationOutcome outcome) implements ServerControlOperationOutcome { + public Ordinary { Objects.requireNonNull(outcome, "outcome"); } + @Override public Classification classification() { return Classification.ORDINARY; } + } + + /** Single-consumption capability delivery result, never a PKI operation value. */ + final class OneTimeSensitive implements ServerControlOperationOutcome { + private final PkiOperationResult safeResult; + private final byte[] token; + private final AtomicBoolean consumed = new AtomicBoolean(); + + /** Creates a one-time result after its commitment has been durably persisted. */ + public OneTimeSensitive(PkiOperationResult safeResult, byte[] token) { + this.safeResult = Objects.requireNonNull(safeResult, "safeResult"); + this.token = Objects.requireNonNull(token, "token").clone(); + if (token.length != 32) throw new IllegalArgumentException("Capability token length is invalid"); + } + @Override public Classification classification() { return Classification.ONE_TIME_SENSITIVE; } + /** @return safe non-secret metadata */ public PkiOperationResult safeResult() { return safeResult; } + /** Consumes the raw token exactly once and clears the retained copy. */ + public byte[] consumeToken() { + if (!consumed.compareAndSet(false, true)) throw new IllegalStateException("Capability token consumed"); + byte[] result = token.clone(); + Arrays.fill(token, (byte) 0); + return result; + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java index 2d0c722..0565477 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerControlStore.java @@ -91,7 +91,7 @@ public final class ServerControlStore implements AutoCloseable { /** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability"; private static final int MAGIC = 0x5a455331; - private static final int SCHEMA = 1; + private static final int SCHEMA = 2; private static final int MAXIMUM_RECORD_BYTES = 1_048_576; private static final int MAXIMUM_STRING_BYTES = 16_384; private static final int MAXIMUM_COLLECTION = 4_096; @@ -179,11 +179,34 @@ public final class ServerControlStore implements AutoCloseable { .orElseThrow(() -> new IllegalArgumentException("Principal is unavailable")); } + /** Returns a bounded stable page of principals in metadata-key order. */ + public synchronized Page principals(int offset, int limit) { + return scanPage(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal, offset, limit); + } + /** Creates one scoped role assignment. */ public synchronized void createAssignment(RoleTemplateCatalog.Assignment value) { create(ASSIGNMENT, value.assignmentId(), output -> writeAssignment(output, value)); } + /** Returns one role assignment. */ + public synchronized RoleTemplateCatalog.Assignment requireAssignment(String assignmentId) { + return read(ASSIGNMENT, assignmentId, KIND_ASSIGNMENT, ServerControlStore::readAssignment) + .orElseThrow(() -> new IllegalArgumentException("Role assignment is unavailable")); + } + + /** Replaces one exact role assignment. */ + public synchronized void replaceAssignment(RoleTemplateCatalog.Assignment current, + RoleTemplateCatalog.Assignment updated) { + requireSame(current.assignmentId(), updated.assignmentId()); + replace(ASSIGNMENT, current.assignmentId(), output -> writeAssignment(output, updated)); + } + + /** Returns a bounded stable page of role assignments. */ + public synchronized Page assignments(int offset, int limit) { + return scanPage(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment, offset, limit); + } + /** Lists assignments for one principal from one stable metadata snapshot. */ public synchronized List assignmentsFor(String principalId) { return scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment).stream() @@ -195,6 +218,17 @@ public final class ServerControlStore implements AutoCloseable { create(GRANT, value.grantId(), output -> writeGrant(output, value)); } + /** Returns one direct grant. */ + public synchronized Permission.Grant requireGrant(String grantId) { + return read(GRANT, grantId, KIND_GRANT, ServerControlStore::readGrant) + .orElseThrow(() -> new IllegalArgumentException("Permission grant is unavailable")); + } + + /** Returns a bounded stable page of direct grants. */ + public synchronized Page grants(int offset, int limit) { + return scanPage(GRANT, KIND_GRANT, ServerControlStore::readGrant, offset, limit); + } + /** Replaces one direct grant. */ public synchronized void replaceGrant(Permission.Grant current, Permission.Grant updated) { requireSame(current.grantId(), updated.grantId()); @@ -224,6 +258,11 @@ public final class ServerControlStore implements AutoCloseable { .orElseThrow(() -> new IllegalArgumentException("Approval is unavailable")); } + /** Returns a bounded stable page of approval records. */ + public synchronized Page approvals(int offset, int limit) { + return scanPage(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval, offset, limit); + } + /** Creates one break-glass record. */ public synchronized void createBreakGlass(BreakGlassService.Record value) { create(BREAK_GLASS, value.breakGlassId(), output -> writeBreakGlass(output, value)); @@ -247,6 +286,11 @@ public final class ServerControlStore implements AutoCloseable { .filter(item -> item.principalId().equals(principalId)).toList(); } + /** Returns a bounded stable page of break-glass records. */ + public synchronized Page breakGlass(int offset, int limit) { + return scanPage(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass, offset, limit); + } + /** Creates one disclosure record. */ public synchronized void createDisclosure(DisclosureService.Record value) { create(DISCLOSURE, value.objectId().value(), output -> writeDisclosure(output, value)); @@ -264,6 +308,11 @@ public final class ServerControlStore implements AutoCloseable { .orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable")); } + /** Returns a bounded stable page of disclosure records. */ + public synchronized Page disclosures(int offset, int limit) { + return scanPage(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure, offset, limit); + } + /** Creates one commitment-only capability record. */ public synchronized void createCapability(DisclosureService.Capability value) { create(CAPABILITY, value.capabilityId(), output -> writeCapability(output, value)); @@ -282,6 +331,15 @@ public final class ServerControlStore implements AutoCloseable { .orElseThrow(() -> new IllegalArgumentException("Capability is unavailable")); } + /** Finite page returned without materializing the complete namespace. */ + public record Page(List values, int nextOffset, boolean hasMore) { + /** Snapshots the bounded page. */ + public Page { + values = List.copyOf(Objects.requireNonNull(values, "values")); + if (nextOffset < 0) throw new IllegalArgumentException("Next offset is negative"); + } + } + /** Lists capability commitments bound to one object. */ public synchronized List capabilitiesFor(PkiId objectId) { return scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability).stream() @@ -330,6 +388,10 @@ public final class ServerControlStore implements AutoCloseable { : scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure)) { record.ownerPrincipalId().ifPresent(owner -> requirePrincipalReference(principals, owner)); } + for (DisclosureService.Capability capability + : scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability)) { + requirePrincipalReference(principals, capability.issuerPrincipalId()); + } } /** Opens no sidecar authority and closes the sole metadata authority idempotently. */ @@ -379,6 +441,34 @@ public final class ServerControlStore implements AutoCloseable { } } + private Page scanPage(String namespace, int kind, Decoder decoder, int offset, int limit) { + requireOpen(); + if (offset < 0 || limit <= 0 || limit > 256) { + throw new IllegalArgumentException("Control page bounds are invalid"); + } + List values = new ArrayList<>(limit); + int seen = 0; + boolean more = false; + try (MetadataSnapshot snapshot = metadata.snapshot(); + MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(namespace), + CancellationSignal.NONE)) { + Optional next; + while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) { + MetadataSnapshot.Record record = next.orElseThrow(); + T decoded = decode(record, kind, decoder); + if (!record.key().equals(key(namespace, identityOf(decoded)))) { + throw new IllegalStateException("Server-control metadata key mismatch"); + } + if (seen++ < offset) continue; + if (values.size() == limit) { more = true; break; } + values.add(decoded); + } + return new Page<>(values, Math.addExact(offset, values.size()), more); + } catch (IOException failure) { + throw new IllegalStateException("Server-control metadata scan failed"); + } + } + private void create(String namespace, String identity, Encoder encoder) { mutate(namespace, identity, true, encoder); } @@ -513,12 +603,12 @@ public final class ServerControlStore implements AutoCloseable { private static void writeAssignment(DataOutputStream out, RoleTemplateCatalog.Assignment value) throws IOException { out.writeInt(KIND_ASSIGNMENT); writeString(out, value.assignmentId()); writeString(out, value.principalId()); writeString(out, value.templateId()); out.writeInt(value.templateVersion()); writeScope(out, value.scope()); - out.writeBoolean(value.enabled()); + writeOptionalInstant(out, value.expiresAt()); out.writeBoolean(value.enabled()); } private static RoleTemplateCatalog.Assignment readAssignment(DataInputStream in) throws IOException { return new RoleTemplateCatalog.Assignment(readString(in), readString(in), readString(in), in.readInt(), - readScope(in), in.readBoolean()); + readScope(in), readOptionalInstant(in), in.readBoolean()); } private static void writeGrant(DataOutputStream out, Permission.Grant value) throws IOException { @@ -580,13 +670,13 @@ public final class ServerControlStore implements AutoCloseable { private static void writeBreakGlass(DataOutputStream out, BreakGlassService.Record value) throws IOException { out.writeInt(KIND_BREAK_GLASS); writeString(out, value.breakGlassId()); writeString(out, value.principalId()); writeGrantBody(out, value.grant()); writeString(out, value.reason()); writeString(out, value.issuerPrincipalId()); - writeOptionalString(out, value.approvalId()); writeInstant(out, value.createdAt()); writeInstant(out, value.activatedAt()); + writeOptionalString(out, value.approvalId()); writeInstant(out, value.createdAt()); writeOptionalInstant(out, value.activatedAt()); writeInstant(out, value.expiresAt()); out.writeInt(value.state().code()); writeString(out, value.auditCommitment()); } private static BreakGlassService.Record readBreakGlass(DataInputStream in) throws IOException { return new BreakGlassService.Record(readString(in), readString(in), readGrantBody(in), readString(in), - readString(in), readOptionalString(in), readInstant(in), readInstant(in), readInstant(in), + readString(in), readOptionalString(in), readInstant(in), readOptionalInstant(in), readInstant(in), BreakGlassService.State.fromCode(in.readInt()), readString(in)); } @@ -603,13 +693,15 @@ public final class ServerControlStore implements AutoCloseable { private static void writeCapability(DataOutputStream out, DisclosureService.Capability value) throws IOException { out.writeInt(KIND_CAPABILITY); writeString(out, value.capabilityId()); writeString(out, value.realmId().value()); - writeString(out, value.objectId().value()); writeString(out, value.action()); writeBytes(out, value.tokenCommitment()); - writeInstant(out, value.expiresAt()); out.writeBoolean(value.revoked()); + writeString(out, value.objectId().value()); writeString(out, value.action()); + writeString(out, value.issuerPrincipalId()); writeBytes(out, value.tokenCommitment()); + writeInstant(out, value.expiresAt()); out.writeInt(value.deliveryState().code()); out.writeBoolean(value.revoked()); } private static DisclosureService.Capability readCapability(DataInputStream in) throws IOException { return new DisclosureService.Capability(readString(in), new RealmId(readString(in)), new PkiId(readString(in)), - readString(in), readBytes(in, 32), readInstant(in), in.readBoolean()); + readString(in), readString(in), readBytes(in, 32), readInstant(in), + DisclosureService.DeliveryState.fromCode(in.readInt()), in.readBoolean()); } private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException { diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java index bfa0a68..e5e701b 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerOperationGateway.java @@ -58,7 +58,8 @@ import zeroecho.pki.application.PkiResourceScopeResolver; * exactly once to the session executor, and preserves the returned outcome.

*/ @SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass", - "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" }) + "PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.NcssCount", "PMD.ConfusingTernary", "PMD.ExceptionAsFlowControl" }) public final class ServerOperationGateway { /** * Complete transport-neutral request admission input. @@ -72,7 +73,7 @@ public final class ServerOperationGateway { * @param approvalId optional durable approval reference * @param correlationId safe finite request correlation identity */ - public record Request(RealmId realmId, String principalId, PkiOperation operation, + public record Request(RealmId realmId, String principalId, AdministrativeOperation operation, Permission.Resource resource, Permission.Relationship relationship, Permission.Context context, Optional approvalId, String correlationId) { /** Validates the immutable request. */ @@ -86,14 +87,27 @@ public final class ServerOperationGateway { approvalId = Objects.requireNonNull(approvalId, "approvalId"); Permission.requireBounded(correlationId, 256, "correlation ID"); } + + /** Wraps an existing PKI operation for source-compatible embedded callers. */ + public Request(RealmId realmId, String principalId, PkiOperation operation, + Permission.Resource resource, Permission.Relationship relationship, Permission.Context context, + Optional approvalId, String correlationId) { + this(realmId, principalId, new AdministrativeOperation.Pki(operation), resource, relationship, + context, approvalId, correlationId); + } } /** Closed gateway outcome preserving typed backend results. */ - public sealed interface Outcome permits Outcome.Executed, Outcome.Denied, Outcome.ApprovalRequired { + public sealed interface Outcome permits Outcome.Executed, Outcome.ControlExecuted, + Outcome.Denied, Outcome.ApprovalRequired { /** Successfully admitted operation and exact backend outcome. */ record Executed(PkiOperationOutcome outcome) implements Outcome { /** Validates the backend result. */ public Executed { Objects.requireNonNull(outcome, "outcome"); } } + /** Successfully admitted server-control result. */ + record ControlExecuted(ServerControlOperationOutcome outcome) implements Outcome { + /** Validates the control result. */ public ControlExecuted { Objects.requireNonNull(outcome, "outcome"); } + } /** Safe denial without protected-object existence information. */ record Denied(AuthorizationEngine.Code code) implements Outcome { /** Validates the safe code. */ public Denied { Objects.requireNonNull(code, "code"); } @@ -117,6 +131,7 @@ public final class ServerOperationGateway { private final BreakGlassService breakGlass; private final OperationSecurityDescriptors descriptors; private final PkiOperationExecutor executor; + private final ServerControlOperationExecutor controlExecutor; private final PkiResourceScopeResolver resourceScopes; private final Map approvalPolicies; private final SafeAudit audit; @@ -125,7 +140,8 @@ public final class ServerOperationGateway { /** Creates one gateway bound to one realm and one session executor. */ public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control, RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, - BreakGlassService breakGlass, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor, + BreakGlassService breakGlass, DisclosureService disclosure, AuditorViews auditorViews, + OperationSecurityDescriptors descriptors, PkiOperationExecutor executor, PkiResourceScopeResolver resourceScopes, Map approvalPolicies, java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) { @@ -138,12 +154,27 @@ public final class ServerOperationGateway { this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass"); this.descriptors = Objects.requireNonNull(descriptors, "descriptors"); this.executor = Objects.requireNonNull(executor, "executor"); + this.controlExecutor = new ServerControlOperationExecutor(realmId, exposure, control, roles, + authorization, approvals, breakGlass, disclosure, auditorViews, descriptors, approvalPolicies); this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes"); this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies")); this.audit = new SafeAudit(clock, auditSink); this.openCheck = Objects.requireNonNull(openCheck, "openCheck"); } + /** Creates the pre-control-plane gateway surface for embedded source compatibility. */ + public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control, + RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals, + BreakGlassService breakGlass, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor, + PkiResourceScopeResolver resourceScopes, + Map approvalPolicies, + java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) { + this(realmId, exposure, control, roles, authorization, approvals, breakGlass, + new DisclosureService(realmId, control, DisclosureService.Defaults.recommended(), clock, + new java.security.SecureRandom(), auditSink), new AuditorViews(clock, auditSink), + descriptors, executor, resourceScopes, approvalPolicies, clock, auditSink, openCheck); + } + /** * Admits and synchronously executes one operation. * @@ -161,12 +192,21 @@ public final class ServerOperationGateway { OperationSecurityDescriptors.Descriptor descriptor; try { descriptor = descriptors.require(exact.operation()); - descriptors.validateResource(exact.operation(), exact.resource()); - validateAuthoritativeScope(exact.operation(), exact.resource()); - } catch (SecurityException invalid) { + if (exact.operation() instanceof AdministrativeOperation.Pki pki) { + descriptors.validateResource(pki.operation(), exact.resource()); + validateAuthoritativeScope(pki.operation(), exact.resource()); + } else if (descriptor.resourceType() != exact.resource().type()) { + throw new SecurityException("Control operation resource type differs"); + } else { + validateControlScope(((AdministrativeOperation.Control) exact.operation()).operation(), + exact.resource()); + } + } catch (SecurityException | IllegalArgumentException invalid) { return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT); } - if (exact.operation() instanceof PkiOperation.CreateAuthority && !exposure.authorityCreationPermitted()) { + if (exact.operation() instanceof AdministrativeOperation.Pki pki + && pki.operation() instanceof PkiOperation.CreateAuthority + && !exposure.authorityCreationPermitted()) { return denied(exact, AuthorizationEngine.Code.OUTSIDE_AUTHORITY_SCOPE); } SecurityPrincipal principal; @@ -181,16 +221,36 @@ public final class ServerOperationGateway { } BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId()); grants.addAll(emergency.grants()); - Permission.Action action = action(exact.operation(), descriptor.action()); + Permission.Action action = exact.operation() instanceof AdministrativeOperation.Pki pki + ? action(pki.operation(), descriptor.action()) : descriptor.action(); Permission.Context conditionContext = approvalContext(exact); AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId, exposure, principal, action, exact.resource(), exact.relationship(), descriptor.dataView(), conditionContext, grants, emergency.grantIds())); - boolean filterableList = exact.operation() instanceof PkiOperation.ListAuthorities + boolean filterableList = exact.operation() instanceof AdministrativeOperation.Pki pki + && pki.operation() instanceof PkiOperation.ListAuthorities && decision.code() == AuthorizationEngine.Code.NO_MATCHING_GRANT; if (!decision.allowed() && !filterableList) { return denied(exact, decision.code()); } + if (exact.operation() instanceof AdministrativeOperation.Control controlOperation + && controlOperation.operation() instanceof ServerControlOperation.RequestApproval requestApproval) { + OperationSecurityDescriptors.Descriptor target = descriptors.require(requestApproval.targetOperation()); + if (target.resourceType() != requestApproval.targetResource().type()) { + return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT); + } + if (requestApproval.targetOperation() instanceof AdministrativeOperation.Pki targetPki) { + try { + descriptors.validateResource(targetPki.operation(), requestApproval.targetResource()); + } catch (SecurityException mismatch) { + return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT); + } + } + AuthorizationEngine.Decision targetDecision = authorization.authorize(new AuthorizationEngine.Request( + realmId, exposure, principal, target.action(), requestApproval.targetResource(), + exact.relationship(), target.dataView(), exact.context(), grants, emergency.grantIds())); + if (!targetDecision.allowed()) return denied(exact, targetDecision.code()); + } if (decision.usedBreakGlass()) breakGlass.auditUse(principal.principalId()); String commitment = descriptors.commitment(realmId, exact.operation(), exact.resource()); Optional claimedApproval = Optional.empty(); @@ -205,17 +265,25 @@ public final class ServerOperationGateway { approvals.claim(current.approvalId(), exact.operation().name(), commitment, exact.resource().scope()); claimedApproval = Optional.of(current.approvalId()); } - PkiOperationOutcome backend = executor.execute(exact.operation(), cancellation); - if (exact.operation() instanceof PkiOperation.ListAuthorities) { - backend = filterAuthorities(backend, principal, grants, emergency.grantIds(), exact.context()); - } - if (claimedApproval.isPresent()) { - approvals.complete(claimedApproval.orElseThrow(), classification(backend)); + if (exact.operation() instanceof AdministrativeOperation.Pki pki) { + PkiOperationOutcome backend = executor.execute(pki.operation(), cancellation); + if (pki.operation() instanceof PkiOperation.ListAuthorities) { + backend = filterAuthorities(backend, principal, grants, emergency.grantIds(), exact.context()); + } + if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), classification(backend)); + audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(), + Map.of("operation", exact.operation().name(), "result", classification(backend), + "correlation", exact.correlationId())); + return new Outcome.Executed(backend); } + ServerControlOperation controlOperation = ((AdministrativeOperation.Control) exact.operation()).operation(); + ServerControlOperationOutcome controlResult = controlExecutor.execute(controlOperation, + principal.principalId(), exact.resource(), exact.approvalId(), cancellation); + String result = classification(controlResult); + if (claimedApproval.isPresent()) approvals.complete(claimedApproval.orElseThrow(), result); audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(), - Map.of("operation", exact.operation().name(), "result", classification(backend), - "correlation", exact.correlationId())); - return new Outcome.Executed(backend); + Map.of("operation", exact.operation().name(), "result", result, "correlation", exact.correlationId())); + return new Outcome.ControlExecuted(controlResult); } /** @@ -279,6 +347,18 @@ public final class ServerOperationGateway { return descriptors.require(operation); } + /** Resolves one descriptor by its immutable administrative operation family. */ + public OperationSecurityDescriptors.Descriptor descriptor(AdministrativeOperation operation) { + openCheck.run(); + return descriptors.require(operation); + } + + /** Resolves one descriptor by stable ID before closed typed decoding. */ + public OperationSecurityDescriptors.Descriptor descriptor(String operationId) { + openCheck.run(); + return descriptors.require(operationId); + } + private List grants(SecurityPrincipal principal) { List grants = new ArrayList<>(control.grantsFor(principal.principalId())); for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) { @@ -351,6 +431,28 @@ public final class ServerOperationGateway { } } + private void validateControlScope(ServerControlOperation operation, Permission.Resource resource) { + Permission.Scope actual = switch (operation) { + case ServerControlOperation.CreateRoleAssignment value -> value.assignment().scope(); + case ServerControlOperation.InspectRoleAssignment value -> control.requireAssignment(value.assignmentId()).scope(); + case ServerControlOperation.RevokeRoleAssignment value -> control.requireAssignment(value.assignmentId()).scope(); + case ServerControlOperation.CreateGrant value -> value.grant().scope(); + case ServerControlOperation.InspectGrant value -> control.requireGrant(value.grantId()).scope(); + case ServerControlOperation.RevokeGrant value -> control.requireGrant(value.grantId()).scope(); + case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope(); + case ServerControlOperation.RequestApproval value -> value.targetResource().scope(); + case ServerControlOperation.InspectApproval value -> approvals.requireCurrent(value.approvalId()).scope(); + case ServerControlOperation.DecideApproval value -> approvals.requireCurrent(value.approvalId()).scope(); + case ServerControlOperation.CancelApproval value -> approvals.requireCurrent(value.approvalId()).scope(); + case ServerControlOperation.CreateBreakGlass value -> value.grant().scope(); + case ServerControlOperation.ActivateBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope(); + case ServerControlOperation.InspectBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope(); + case ServerControlOperation.RevokeBreakGlass value -> breakGlass.requireCurrent(value.breakGlassId()).grant().scope(); + default -> resource.scope(); + }; + if (!actual.equals(resource.scope())) throw new SecurityException("Control scope differs"); + } + private static boolean requiresResolvedAuthority(PkiOperation operation) { return operation instanceof PkiOperation.InspectCredential || operation instanceof PkiOperation.RevokeCredential @@ -383,4 +485,10 @@ public final class ServerOperationGateway { case PkiOperationOutcome.Failure failure -> failure.classification().name(); }; } + + private static String classification(ServerControlOperationOutcome outcome) { + if (outcome instanceof ServerControlOperationOutcome.OneTimeSensitive) return "SUCCEEDED"; + PkiOperationOutcome ordinary = ((ServerControlOperationOutcome.Ordinary) outcome).outcome(); + return classification(ordinary); + } } diff --git a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java index 24fc041..247d885 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java +++ b/pki-server/src/main/java/zeroecho/pki/server/ServerRealmContext.java @@ -96,7 +96,8 @@ public final class ServerRealmContext implements AutoCloseable { this.auditorViews = auditorViews; this.audit = new SafeAudit(clock, auditSink); this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(), - control, roles, authorization, approvals, breakGlass, new OperationSecurityDescriptors(), + control, roles, authorization, approvals, breakGlass, disclosure, auditorViews, + new OperationSecurityDescriptors(), session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink, this::requireOpen); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java index ff6f9a3..ab9dfd3 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/AdminHttpHandler.java @@ -335,9 +335,11 @@ final class AdminHttpHandler implements HttpHandler { private static PkiOperationValue.ObjectValue descriptorValue(OperationSecurityDescriptors.Descriptor value) { Map fields = new LinkedHashMap<>(); fields.put("operation", new PkiOperationValue.Text(value.operationId())); + fields.put("family", new PkiOperationValue.Text(value.family().name())); fields.put("mutating", new PkiOperationValue.BooleanValue(value.mutating())); fields.put("resourceType", new PkiOperationValue.Text(value.resourceType().name())); fields.put("approval", new PkiOperationValue.Text(value.approvalCategory().name())); + fields.put("dataView", new PkiOperationValue.Text(value.dataView().name())); return new PkiOperationValue.ObjectValue(fields); } @@ -370,20 +372,49 @@ final class AdminHttpHandler implements HttpHandler { "MALFORMED_REQUEST", "MALFORMED_REQUEST", false, false)); } - private static void send(HttpExchange exchange, String requestId, HttpResponses.Response response) + private void send(HttpExchange exchange, String requestId, HttpResponses.Response response) throws IOException { byte[] body = response.body(); exchange.getResponseHeaders().set("Content-Type", JSON); exchange.getResponseHeaders().set("Cache-Control", "no-store"); + if (response.oneTimeSensitive()) { + exchange.getResponseHeaders().set("Pragma", "no-cache"); + exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer"); + } exchange.getResponseHeaders().set(RequestIds.HEADER, requestId); exchange.sendResponseHeaders(response.statusCode(), body.length); try (java.io.OutputStream output = exchange.getResponseBody()) { output.write(body); + classifyCapabilityDelivery(response, true, requestId); + } catch (IOException failure) { + if (response.oneTimeSensitive()) { + try { + classifyCapabilityDelivery(response, false, requestId); + } catch (RuntimeException classificationFailure) { + failure.addSuppressed(classificationFailure); + } + realm.auditTransport("CAPABILITY_DELIVERY_UNKNOWN", "system", + Map.of("request", requestId, "classification", "CAPABILITY_DELIVERY_UNKNOWN")); + } + throw failure; } finally { + java.util.Arrays.fill(body, (byte) 0); + response.clearSensitiveBody(); exchange.close(); } } + private void classifyCapabilityDelivery(HttpResponses.Response response, boolean delivered, + String requestId) { + try { + response.capabilityId().ifPresent(value -> realm.disclosure().classifyDelivery(value, delivered)); + } catch (RuntimeException failure) { + realm.auditTransport("CAPABILITY_DELIVERY_UNKNOWN", "system", + Map.of("request", requestId, "classification", "CAPABILITY_DELIVERY_UNKNOWN")); + if (!delivered) throw failure; + } + } + private static final class TransportFailure extends RuntimeException { private static final long serialVersionUID = 1L; private final transient HttpResponses.Response response; diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java index 01e14d7..2a00549 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpOperationCodec.java @@ -34,6 +34,7 @@ package zeroecho.pki.server.http; import java.time.Duration; +import java.time.Instant; import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; @@ -50,21 +51,27 @@ import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.application.PkiOperation; import zeroecho.pki.application.PkiOperationValue; import zeroecho.pki.server.OperationSecurityDescriptors; +import zeroecho.pki.server.AdministrativeOperation; +import zeroecho.pki.server.ApprovalService; +import zeroecho.pki.server.DisclosureService; import zeroecho.pki.server.Permission; import zeroecho.pki.server.RealmId; +import zeroecho.pki.server.RoleTemplateCatalog; +import zeroecho.pki.server.SecurityPrincipal; +import zeroecho.pki.server.ServerControlOperation; /** Closed strict HTTP decoding for operations exposed by the existing gateway. */ @SuppressWarnings("PMD") final class HttpOperationCodec { /** Fully decoded gateway input excluding authenticated principal and request ID. */ - record Decoded(PkiOperation operation, Permission.Resource resource, Permission.Context context, + record Decoded(AdministrativeOperation operation, Permission.Resource resource, Permission.Context context, Optional approvalId, Optional requestedDeadline) { } private HttpOperationCodec() { } static Decoded decode(String operationId, byte[] document, int maximumBytes, RealmId realmId, - java.util.function.Function descriptors) { + java.util.function.Function descriptors) { Fields root = Fields.of(StrictJson.parse(document, maximumBytes)); root.allowed(Set.of("version", "authorityId", "approvalId", "deadline", "arguments")); if (root.integer("version") != 1) throw new IllegalArgumentException("Request version is unsupported"); @@ -73,22 +80,34 @@ final class HttpOperationCodec { approval.ifPresent(value -> requireId(value, "approval ID")); Optional deadline = root.optionalText("deadline").map(HttpOperationCodec::duration); Fields arguments = root.object("arguments"); - PkiOperation operation = operation(operationId, arguments); + OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operationId); + AdministrativeOperation operation; + Permission.Scope scope; + Optional objectId; + if (descriptor.family() == OperationSecurityDescriptors.Family.PKI_OPERATION) { + PkiOperation pki = operation(operationId, arguments); + operation = new AdministrativeOperation.Pki(pki); + boolean realmWide = realmWide(pki); + if (realmWide == authority.isPresent()) { + throw new IllegalArgumentException(realmWide + ? "Realm-wide operation must not specify authorityId" + : "Authority-scoped operation requires authorityId"); + } + validateAuthority(pki, authority); + scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(pki)); + objectId = objectId(pki); + } else { + ServerControlOperation control = control(operationId, arguments, realmId, authority, descriptors); + operation = new AdministrativeOperation.Control(control); + scope = controlScope(control, realmId, authority); + objectId = controlObject(control); + } arguments.complete(); root.complete(); - OperationSecurityDescriptors.Descriptor descriptor = descriptors.apply(operation); - boolean realmWide = realmWide(operation); - if (realmWide == authority.isPresent()) { - throw new IllegalArgumentException(realmWide - ? "Realm-wide operation must not specify authorityId" - : "Authority-scoped operation requires authorityId"); - } - validateAuthority(operation, authority); - Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), profile(operation)); - Optional objectId = objectId(operation); Permission.Resource resource = new Permission.Resource(descriptor.resourceType(), scope, objectId, Optional.empty()); - Optional reason = operation instanceof PkiOperation.TransitionAuthority transition + Optional reason = operation instanceof AdministrativeOperation.Pki pki + && pki.operation() instanceof PkiOperation.TransitionAuthority transition ? Optional.of(transition.reason()) : Optional.empty(); Permission.Context context = new Permission.Context(reason, approval.isPresent(), false, Map.of()); return new Decoded(operation, resource, context, approval, deadline); @@ -167,6 +186,212 @@ final class HttpOperationCodec { }; } + private static ServerControlOperation control(String id, Fields fields, RealmId realmId, + Optional authority, + java.util.function.Function descriptors) { + return switch (id) { + case ServerControlOperation.RegisterPrincipal.NAME -> { + fields.exact("principalId", "type", "displayName", "enabled"); + yield new ServerControlOperation.RegisterPrincipal(new SecurityPrincipal(fields.text("principalId"), + SecurityPrincipal.Type.valueOf(fields.text("type")), fields.text("displayName"), + Optional.empty(), Map.of(), fields.bool("enabled"))); + } + case ServerControlOperation.InspectPrincipal.NAME -> { + fields.exact("principalId"); yield new ServerControlOperation.InspectPrincipal(fields.text("principalId")); + } + case ServerControlOperation.ListPrincipals.NAME -> { + fields.exact("offset", "limit"); yield new ServerControlOperation.ListPrincipals( + fields.integer("offset"), fields.integer("limit")); + } + case ServerControlOperation.SetPrincipalEnabled.ENABLE, + ServerControlOperation.SetPrincipalEnabled.DISABLE -> { + fields.exact("principalId"); yield new ServerControlOperation.SetPrincipalEnabled( + fields.text("principalId"), id.equals(ServerControlOperation.SetPrincipalEnabled.ENABLE)); + } + case ServerControlOperation.ListRoleTemplates.NAME -> { + fields.exact(); yield new ServerControlOperation.ListRoleTemplates(); + } + case ServerControlOperation.InspectRoleTemplate.NAME -> { + fields.exact("templateId", "version"); yield new ServerControlOperation.InspectRoleTemplate( + fields.text("templateId"), fields.integer("version")); + } + case ServerControlOperation.CreateRoleAssignment.NAME -> { + fields.allowed(Set.of("assignmentId", "principalId", "templateId", "templateVersion", + "profileId", "expiresAt")); + Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), + fields.optionalText("profileId")); + yield new ServerControlOperation.CreateRoleAssignment(new RoleTemplateCatalog.Assignment( + fields.text("assignmentId"), fields.text("principalId"), fields.text("templateId"), + fields.integer("templateVersion"), scope, fields.optionalInstant("expiresAt"), true)); + } + case ServerControlOperation.InspectRoleAssignment.NAME -> { + fields.exact("assignmentId"); yield new ServerControlOperation.InspectRoleAssignment( + fields.text("assignmentId")); + } + case ServerControlOperation.ListRoleAssignments.NAME -> { + fields.exact("offset", "limit"); yield new ServerControlOperation.ListRoleAssignments( + fields.integer("offset"), fields.integer("limit")); + } + case ServerControlOperation.RevokeRoleAssignment.NAME -> { + fields.exact("assignmentId"); yield new ServerControlOperation.RevokeRoleAssignment( + fields.text("assignmentId")); + } + case ServerControlOperation.CreateGrant.NAME -> { + fields.allowed(Set.of("grantId", "principalId", "effect", "action", "resourceType", + "issuerId", "profileId", "relationship", "dataView", "conditions", "expiresAt")); + Permission.Scope scope = new Permission.Scope(realmId, authority, + fields.optionalText("issuerId").map(PkiId::new), fields.optionalText("profileId")); + yield new ServerControlOperation.CreateGrant(new Permission.Grant(fields.text("grantId"), + fields.text("principalId"), Permission.Effect.valueOf(fields.text("effect")), + Permission.Action.valueOf(fields.text("action")), + Permission.ResourceType.valueOf(fields.text("resourceType")), scope, + Permission.Relationship.valueOf(fields.text("relationship")), + Permission.DataView.valueOf(fields.text("dataView")), + fields.enumSet("conditions", Permission.Condition.class), fields.optionalInstant("expiresAt"), true)); + } + case ServerControlOperation.InspectGrant.NAME -> { + fields.exact("grantId"); yield new ServerControlOperation.InspectGrant(fields.text("grantId")); + } + case ServerControlOperation.ListGrants.NAME -> { + fields.exact("offset", "limit"); yield new ServerControlOperation.ListGrants( + fields.integer("offset"), fields.integer("limit")); + } + case ServerControlOperation.RevokeGrant.NAME -> { + fields.exact("grantId"); yield new ServerControlOperation.RevokeGrant(fields.text("grantId")); + } + case ServerControlOperation.EvaluateAuthorization.NAME -> { + fields.exact("principalId", "action", "resourceType", "relationship", "dataView"); + Permission.Resource resource = new Permission.Resource( + Permission.ResourceType.valueOf(fields.text("resourceType")), + new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()), + Optional.empty(), Optional.empty()); + yield new ServerControlOperation.EvaluateAuthorization(fields.text("principalId"), + Permission.Action.valueOf(fields.text("action")), resource, + Permission.Relationship.valueOf(fields.text("relationship")), + Permission.DataView.valueOf(fields.text("dataView")), Permission.Context.empty()); + } + case ServerControlOperation.RequestApproval.NAME -> { + fields.allowed(Set.of("approvalId", "targetOperation", "targetArguments", "targetAuthorityId")); + String targetId = fields.text("targetOperation"); + Fields targetFields = fields.object("targetArguments"); + Optional targetAuthority = fields.optionalText("targetAuthorityId").map(PkiId::new); + OperationSecurityDescriptors.Descriptor targetDescriptor = descriptors.apply(targetId); + AdministrativeOperation targetOperation; + Permission.Scope targetScope; + Optional targetObject; + if (targetDescriptor.family() == OperationSecurityDescriptors.Family.PKI_OPERATION) { + PkiOperation targetPki = operation(targetId, targetFields); + targetOperation = new AdministrativeOperation.Pki(targetPki); + targetScope = new Permission.Scope(realmId, targetAuthority, Optional.empty(), profile(targetPki)); + targetObject = objectId(targetPki); + } else { + ServerControlOperation targetControl = control(targetId, targetFields, realmId, + targetAuthority, descriptors); + targetOperation = new AdministrativeOperation.Control(targetControl); + targetScope = controlScope(targetControl, realmId, targetAuthority); + targetObject = controlObject(targetControl); + } + targetFields.complete(); + Permission.Resource targetResource = new Permission.Resource(targetDescriptor.resourceType(), + targetScope, targetObject, Optional.empty()); + yield new ServerControlOperation.RequestApproval(fields.text("approvalId"), + targetOperation, targetResource); + } + case ServerControlOperation.InspectApproval.NAME -> { + fields.exact("approvalId"); yield new ServerControlOperation.InspectApproval(fields.text("approvalId")); + } + case ServerControlOperation.ListApprovals.NAME -> { + fields.allowed(Set.of("offset", "limit", "state", "requesterPrincipalId", "targetOperationId")); + yield new ServerControlOperation.ListApprovals(fields.integer("offset"), fields.integer("limit"), + fields.optionalText("state").map(ApprovalService.State::valueOf), + fields.optionalText("requesterPrincipalId"), fields.optionalText("targetOperationId")); + } + case ServerControlOperation.DecideApproval.APPROVE, + ServerControlOperation.DecideApproval.REJECT -> { + fields.exact("approvalId", "justification"); yield new ServerControlOperation.DecideApproval( + fields.text("approvalId"), id.equals(ServerControlOperation.DecideApproval.APPROVE) + ? ApprovalService.Choice.APPROVE : ApprovalService.Choice.REJECT, + fields.text("justification")); + } + case ServerControlOperation.CancelApproval.NAME -> { + fields.exact("approvalId"); yield new ServerControlOperation.CancelApproval(fields.text("approvalId")); + } + case ServerControlOperation.CreateBreakGlass.CREATE -> { + fields.exact("breakGlassId", "principalId", "grantId", "action", "resourceType", "reason", "expiresAt"); + Permission.Scope scope = new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()); + Permission.Grant grant = new Permission.Grant(fields.text("grantId"), fields.text("principalId"), + Permission.Effect.ALLOW, Permission.Action.valueOf(fields.text("action")), + Permission.ResourceType.valueOf(fields.text("resourceType")), scope, + Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED, Set.of(), + Optional.of(fields.instant("expiresAt")), true); + yield new ServerControlOperation.CreateBreakGlass(fields.text("breakGlassId"), + fields.text("principalId"), grant, fields.text("reason"), fields.instant("expiresAt")); + } + case ServerControlOperation.ActivateBreakGlass.ACTIVATE -> { + fields.exact("breakGlassId"); + yield new ServerControlOperation.ActivateBreakGlass(fields.text("breakGlassId")); + } + case ServerControlOperation.InspectBreakGlass.NAME -> { + fields.exact("breakGlassId"); yield new ServerControlOperation.InspectBreakGlass(fields.text("breakGlassId")); + } + case ServerControlOperation.ListBreakGlass.NAME -> { + fields.exact("offset", "limit"); yield new ServerControlOperation.ListBreakGlass( + fields.integer("offset"), fields.integer("limit")); + } + case ServerControlOperation.RevokeBreakGlass.NAME -> { + fields.exact("breakGlassId"); yield new ServerControlOperation.RevokeBreakGlass(fields.text("breakGlassId")); + } + case ServerControlOperation.InspectDisclosure.NAME -> { + fields.exact("objectId"); yield new ServerControlOperation.InspectDisclosure(fields.pkiId("objectId")); + } + case ServerControlOperation.SetDisclosure.NAME -> { + fields.exact("objectId", "policy"); yield new ServerControlOperation.SetDisclosure( + fields.pkiId("objectId"), DisclosureService.Policy.valueOf(fields.text("policy"))); + } + case ServerControlOperation.IssueCapability.NAME -> { + fields.exact("objectId", "expiresAt"); yield new ServerControlOperation.IssueCapability( + fields.pkiId("objectId"), fields.instant("expiresAt")); + } + case ServerControlOperation.RevokeCapability.NAME -> { + fields.exact("capabilityId"); yield new ServerControlOperation.RevokeCapability(fields.text("capabilityId")); + } + case ServerControlOperation.InspectAuditView.REDACTED, + ServerControlOperation.InspectAuditView.FULL, + ServerControlOperation.InspectAuditView.PII -> { + fields.allowed(Set.of("objectId", "reasonReference")); + Permission.DataView view = id.equals(ServerControlOperation.InspectAuditView.REDACTED) + ? Permission.DataView.METADATA_REDACTED + : id.equals(ServerControlOperation.InspectAuditView.FULL) + ? Permission.DataView.METADATA_FULL : Permission.DataView.PII_FULL; + yield new ServerControlOperation.InspectAuditView(fields.pkiId("objectId"), view, + fields.optionalText("reasonReference")); + } + default -> throw new SecurityException("Control operation is not exposed"); + }; + } + + private static Permission.Scope controlScope(ServerControlOperation operation, RealmId realmId, + Optional authority) { + return switch (operation) { + case ServerControlOperation.CreateRoleAssignment value -> value.assignment().scope(); + case ServerControlOperation.CreateGrant value -> value.grant().scope(); + case ServerControlOperation.EvaluateAuthorization value -> value.resource().scope(); + case ServerControlOperation.RequestApproval value -> value.targetResource().scope(); + case ServerControlOperation.CreateBreakGlass value -> value.grant().scope(); + default -> new Permission.Scope(realmId, authority, Optional.empty(), Optional.empty()); + }; + } + + private static Optional controlObject(ServerControlOperation operation) { + return switch (operation) { + case ServerControlOperation.InspectDisclosure value -> Optional.of(value.objectId()); + case ServerControlOperation.SetDisclosure value -> Optional.of(value.objectId()); + case ServerControlOperation.IssueCapability value -> Optional.of(value.objectId()); + case ServerControlOperation.InspectAuditView value -> Optional.of(value.objectId()); + default -> Optional.empty(); + }; + } + private static boolean realmWide(PkiOperation operation) { return operation instanceof PkiOperation.ListAuthorities || operation instanceof PkiOperation.CreateAuthority @@ -247,7 +472,24 @@ final class HttpOperationCodec { long longValue(String name) { consumed.add(name); PkiOperationValue value = require(name); if (!(value instanceof PkiOperationValue.IntegerValue integer)) throw type(); return integer.value(); } int integer(String name) { return Math.toIntExact(longValue(name)); } + boolean bool(String name) { consumed.add(name); PkiOperationValue value = require(name); + if (!(value instanceof PkiOperationValue.BooleanValue bool)) throw type(); return bool.value(); } PkiId pkiId(String name) { return new PkiId(text(name)); } + Instant instant(String name) { try { return Instant.parse(text(name)); } + catch (java.time.format.DateTimeParseException failure) { throw type(); } } + Optional optionalInstant(String name) { return fields.containsKey(name) + ? Optional.of(instant(name)) : Optional.empty(); } + > Set enumSet(String name, Class type) { + consumed.add(name); PkiOperationValue value = require(name); + if (!(value instanceof PkiOperationValue.ListValue list)) throw type(); + Set result = new LinkedHashSet<>(); + for (PkiOperationValue item : list.values()) { + if (!(item instanceof PkiOperationValue.Text text) || !result.add(Enum.valueOf(type, text.value()))) { + throw type(); + } + } + return Set.copyOf(result); + } Fields object(String name) { consumed.add(name); return of(require(name)); } void complete() { if (!Objects.equals(consumed, fields.keySet())) throw new IllegalArgumentException("Request fields were not consumed"); } diff --git a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java index 16c599e..7875241 100644 --- a/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java +++ b/pki-server/src/main/java/zeroecho/pki/server/http/HttpResponses.java @@ -43,13 +43,18 @@ import zeroecho.pki.application.PkiOperationFailure; import zeroecho.pki.application.PkiOperationOutcome; import zeroecho.pki.application.PkiOperationValue; import zeroecho.pki.server.ServerOperationGateway; +import zeroecho.pki.server.ServerControlOperationOutcome; /** Deterministic versioned transport response and status mapping. */ @SuppressWarnings("PMD") final class HttpResponses { - record Response(int statusCode, byte[] body) { - Response { body = Objects.requireNonNull(body, "body").clone(); } + record Response(int statusCode, byte[] body, boolean oneTimeSensitive, + java.util.Optional capabilityId) { + Response { body = Objects.requireNonNull(body, "body").clone(); + capabilityId = Objects.requireNonNull(capabilityId, "capabilityId"); } + Response(int statusCode, byte[] body) { this(statusCode, body, false, java.util.Optional.empty()); } @Override public byte[] body() { return body.clone(); } + void clearSensitiveBody() { if (oneTimeSensitive) java.util.Arrays.fill(body, (byte) 0); } } private static final JsonFactory JSON = JsonFactory.builder().build(); @@ -61,6 +66,8 @@ final class HttpResponses { return switch (outcome) { case ServerOperationGateway.Outcome.Executed executed -> backend(requestId, operation, executed.outcome()); + case ServerOperationGateway.Outcome.ControlExecuted executed -> control(requestId, operation, + executed.outcome()); case ServerOperationGateway.Outcome.Denied denied -> failure(403, requestId, operation, "DENIED", "AUTHORIZATION_DENIED", denied.code().name(), false, false); case ServerOperationGateway.Outcome.ApprovalRequired required -> successLikeFailure(409, requestId, @@ -68,6 +75,27 @@ final class HttpResponses { }; } + private static Response control(String requestId, String operation, ServerControlOperationOutcome outcome) { + if (outcome instanceof ServerControlOperationOutcome.Ordinary ordinary) { + return backend(requestId, operation, ordinary.outcome()); + } + ServerControlOperationOutcome.OneTimeSensitive sensitive = + (ServerControlOperationOutcome.OneTimeSensitive) outcome; + byte[] token = sensitive.consumeToken(); + try { + MapBuilder result = new MapBuilder(sensitive.safeResult().fields()); + result.put("token", new PkiOperationValue.Text(java.util.Base64.getUrlEncoder() + .withoutPadding().encodeToString(token))); + Response encoded = encode(200, requestId, operation, "SUCCEEDED", + new PkiOperationValue.ObjectValue(result.values()), null); + String capabilityId = ((PkiOperationValue.Text) sensitive.safeResult().fields() + .get("capabilityId")).value(); + return new Response(encoded.statusCode(), encoded.body(), true, java.util.Optional.of(capabilityId)); + } finally { + java.util.Arrays.fill(token, (byte) 0); + } + } + static Response failure(int status, String requestId, String operation, String classification, String code, String messageKey, boolean recoveryRequired, boolean reconciliationRequired) { return encode(status, requestId, operation, "FAILED", null, @@ -153,4 +181,11 @@ final class HttpResponses { private record Failure(String classification, String code, String messageKey, boolean recoveryRequired, boolean reconciliationRequired) { } + + private static final class MapBuilder { + private final java.util.Map values = new java.util.LinkedHashMap<>(); + private MapBuilder(java.util.Map initial) { values.putAll(initial); } + private void put(String key, PkiOperationValue value) { values.put(key, value); } + private java.util.Map values() { return values; } + } } diff --git a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json index 3c32cda..1ef0396 100644 --- a/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json +++ b/pki-server/src/main/resources/zeroecho/pki/server/security/role-templates-v1.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "roles": [ {"id":"platform-operator","version":1,"actions":["REALM_READ","SERVER_HEALTH_READ","SERVER_CONFIGURATION_READ","SERVER_CONFIGURATION_UPDATE"]}, - {"id":"security-administrator","version":1,"actions":["REALM_READ","IDENTITY_PROVIDER_MANAGE","PRINCIPAL_MANAGE","ROLE_MANAGE","PERMISSION_GRANT"]}, + {"id":"security-administrator","version":1,"actions":["REALM_READ","IDENTITY_PROVIDER_MANAGE","PRINCIPAL_MANAGE","ROLE_MANAGE","PERMISSION_GRANT","DISCLOSURE_READ","DISCLOSURE_CHANGE","DISCLOSURE_CAPABILITY_ISSUE","DISCLOSURE_CAPABILITY_REVOKE"]}, {"id":"ca-security-officer","version":1,"actions":["AUTHORITY_LIST","AUTHORITY_READ","AUTHORITY_CREATE","AUTHORITY_IMPORT","AUTHORITY_ACTIVATE","AUTHORITY_SUSPEND","AUTHORITY_RETIRE","ISSUER_CREATE","ISSUER_ROTATE","ISSUER_RETIRE","CA_CHAIN_DOWNLOAD"]}, {"id":"profile-policy-manager","version":1,"actions":["PROFILE_READ","PROFILE_REGISTER","PROFILE_VALIDATE","PROFILE_ACTIVATE","PROFILE_DEACTIVATE","POLICY_READ","POLICY_UPDATE","X509_BINDING_READ","X509_BINDING_PROVIDER_ENABLE"]}, {"id":"enrollment-officer","version":1,"actions":["REQUEST_SUBMIT","REQUEST_READ_ANY","CERTIFICATE_ISSUE","CERTIFICATE_READ_METADATA","CERTIFICATE_DOWNLOAD"]}, @@ -12,8 +12,8 @@ {"id":"publication-operator","version":1,"actions":["PUBLICATION_REGISTER","PUBLICATION_READ","PUBLICATION_PROCESS","PUBLICATION_RETRY","PUBLICATION_RECONCILE"]}, {"id":"backup-operator","version":1,"actions":["BACKUP_EXPORT","BACKUP_VERIFY"]}, {"id":"recovery-officer","version":1,"actions":["RESTORE_EXECUTE"]}, - {"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ"]}, - {"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII"]}, + {"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ","DISCLOSURE_READ"]}, + {"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII","DISCLOSURE_READ"]}, {"id":"acme-administrator","version":1,"actions":["REALM_READ","AUTHORITY_READ","PROFILE_READ","POLICY_READ"]}, {"id":"public-principal","version":1,"actions":["REALM_READ","CA_CHAIN_DOWNLOAD","CRL_DOWNLOAD"]} ] diff --git a/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java b/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java index 50acf3c..1fe3ecf 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ControlApprovalDisclosureTest.java @@ -143,13 +143,33 @@ class ControlApprovalDisclosureTest { Permission.ResourceType.REVOCATION, ServerTestSupport.scope(), Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED); BreakGlassService service = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()); - BreakGlassService.Record active = service.activate("break-a", "officer", emergency, - "incident-response", "security-admin", Optional.empty(), - ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1))); + ApprovalService.Policy emergencyPolicy = new ApprovalService.Policy("emergency-policy", 1, + Set.of("security-admin"), Set.of(), true, Duration.ofHours(1), true); + ApprovalService approvalService = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, + opened.audit()); + ApprovalService.Request creation = approvalService.request("approval-break-create", + ServerControlOperation.CreateBreakGlass.CREATE, ServerTestSupport.DIGEST, + ServerTestSupport.scope(), "officer", emergencyPolicy); + approvalService.decide(creation.approvalId(), "security-admin", ApprovalService.Choice.APPROVE, + "independent emergency review"); + approvalService.claim(creation.approvalId(), ServerControlOperation.CreateBreakGlass.CREATE, + ServerTestSupport.DIGEST, ServerTestSupport.scope()); + service.create("break-a", "officer", emergency, "incident-response", "security-admin", + Optional.of(creation.approvalId()), ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1))); + ApprovalService.Request activation = approvalService.request("approval-break-activate", + ServerControlOperation.ActivateBreakGlass.ACTIVATE, ServerTestSupport.DIGEST, + ServerTestSupport.scope(), "officer", emergencyPolicy); + approvalService.decide(activation.approvalId(), "security-admin", ApprovalService.Choice.APPROVE, + "activation reviewed"); + approvalService.claim(activation.approvalId(), ServerControlOperation.ActivateBreakGlass.ACTIVATE, + ServerTestSupport.DIGEST, ServerTestSupport.scope()); + BreakGlassService.Record active = service.activatePending("break-a", "security-admin", + Optional.of(activation.approvalId())); assertEquals(BreakGlassService.State.ACTIVE, active.state()); assertEquals(1, service.activeFor("officer").grants().size()); - assertThrows(IllegalArgumentException.class, () -> service.activate("break-b", "officer", emergency, - "incident", "officer", Optional.empty(), ServerTestSupport.CLOCK.instant().plusSeconds(60))); + assertThrows(IllegalStateException.class, () -> service.create("break-b", "officer", emergency, + "incident", "security-admin", Optional.empty(), + ServerTestSupport.CLOCK.instant().plusSeconds(60))); assertTrue(opened.audit().events().stream().anyMatch(event -> event.action().contains("ACTIVATE"))); BreakGlassService expired = new BreakGlassService(opened.store(), Clock.fixed(active.expiresAt(), ZoneOffset.UTC), opened.audit()); @@ -164,6 +184,7 @@ class ControlApprovalDisclosureTest { System.out.println("disclosureSeparatesIdentityOwnershipPublicationAndCapability"); try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { opened.store().createPrincipal(ServerTestSupport.principal("owner-a")); + opened.store().createPrincipal(ServerTestSupport.principal("officer")); SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG"); deterministic.setSeed(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); DisclosureService service = new DisclosureService(ServerTestSupport.REALM, opened.store(), @@ -192,24 +213,26 @@ class ControlApprovalDisclosureTest { service.change(leaf, DisclosureService.Policy.PUBLIC_UNLISTED, true, true, "officer"); DisclosureService.IssuedCapability issued = service.issueCapability(leaf, ServerTestSupport.CLOCK.instant().plusSeconds(600), "officer"); - assertNotEquals(java.util.HexFormat.of().formatHex(issued.token()), + byte[] issuedToken = issued.token(); + assertNotEquals(java.util.HexFormat.of().formatHex(issuedToken), java.util.HexFormat.of().formatHex(issued.capability().tokenCommitment())); assertEquals(DisclosureService.Decision.ALLOWED, - service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token()))); + service.decide(leaf, Optional.empty(), false, false, Optional.of(issuedToken))); DisclosureService.Capability wrongAction = new DisclosureService.Capability("cap-wrong-action", ServerTestSupport.REALM, leaf, "INSPECT", issued.capability().tokenCommitment(), issued.capability().expiresAt(), false); opened.store().createCapability(wrongAction); service.revokeCapability(issued.capability().capabilityId(), "officer"); assertEquals(DisclosureService.Decision.DENIED, - service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token()))); - byte[] wrong = issued.token(); + service.decide(leaf, Optional.empty(), false, false, Optional.of(issuedToken))); + byte[] wrong = issuedToken.clone(); wrong[0] ^= 1; assertEquals(DisclosureService.Decision.DENIED, service.decide(leaf, Optional.empty(), false, false, Optional.of(wrong))); assertArrayEquals(issued.capability().tokenCommitment(), opened.store().requireCapability(issued.capability().capabilityId()).tokenCommitment()); - System.out.println("...token-bytes=" + issued.token().length + ", persisted=commitment-only"); + assertThrows(IllegalStateException.class, issued::token); + System.out.println("...token-bytes=" + issuedToken.length + ", persisted=commitment-only"); } System.out.println("...ok"); } diff --git a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java index 069aef3..4586789 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/PkiHttpsServerTest.java @@ -137,6 +137,16 @@ class PkiHttpsServerTest { "{\"version\":1,\"arguments\":{\"limit\":10}}"); assertEquals(200, executed.statusCode()); assertTrue(executed.body().contains("\"status\":\"SUCCEEDED\"")); + HttpResponse principalCreated = post(client, + base.resolve("/admin/v1/operations/security.principal.register"), + "{\"version\":1,\"arguments\":{\"principalId\":\"operator-b\",\"type\":\"USER\"," + + "\"displayName\":\"Operator B\",\"enabled\":true}}"); + assertEquals(200, principalCreated.statusCode()); + HttpResponse principalInspected = post(client, + base.resolve("/admin/v1/operations/security.principal.inspect"), + "{\"version\":1,\"arguments\":{\"principalId\":\"operator-b\"}}"); + assertEquals(200, principalInspected.statusCode()); + assertTrue(principalInspected.body().contains("operator-b")); HttpResponse hidden = get(client, base.resolve("/admin/v1/operations/credential.issue")); assertEquals(404, hidden.statusCode()); HttpResponse malformed = post(client, @@ -198,6 +208,9 @@ class PkiHttpsServerTest { context.grant(ServerTestSupport.grant("binding-read", "administrator", Permission.Effect.ALLOW, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING, realmScope, Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system"); + context.grant(ServerTestSupport.grant("principal-manage", "administrator", Permission.Effect.ALLOW, + Permission.Action.PRINCIPAL_MANAGE, Permission.ResourceType.PRINCIPAL, realmScope, + Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED), "system"); } } diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java new file mode 100644 index 0000000..03a0dc0 --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerControlOperationExecutorTest.java @@ -0,0 +1,238 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.application.PkiOperation; + +/** Durable closed control-operation, approval and one-time-result coverage. */ +class ServerControlOperationExecutorTest { + @TempDir Path temporaryDirectory; + + @Test + void catalogContainsOneExplicitPairOfOperationFamilies() { + System.out.println("catalogContainsOneExplicitPairOfOperationFamilies"); + OperationSecurityDescriptors catalog = new OperationSecurityDescriptors(); + assertEquals(50, catalog.descriptors().size()); + assertEquals(16, catalog.descriptors().values().stream() + .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); + assertEquals(34, catalog.descriptors().values().stream() + .filter(value -> value.family() == OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION) + .count()); + assertEquals(OperationSecurityDescriptors.Family.SERVER_CONTROL_OPERATION, + catalog.require(ServerControlOperation.IssueCapability.NAME).family()); + System.out.println("...control-operations=34"); + System.out.println("...ok"); + } + + @Test + void principalAssignmentAndGrantOperationsPersistAndPage() throws Exception { + System.out.println("principalAssignmentAndGrantOperationsPersistAndPage"); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { + ServerControlOperationExecutor executor = executor(opened); + SecurityPrincipal principal = ServerTestSupport.principal("operator-a"); + ordinary(executor.execute(new ServerControlOperation.RegisterPrincipal(principal), "operator-a", + resource(Permission.ResourceType.PRINCIPAL, realmScope()), Optional.empty(), + CancellationSignal.NONE)); + RoleTemplateCatalog.Assignment assignment = new RoleTemplateCatalog.Assignment("assignment-a", + principal.principalId(), "auditor", 1, ServerTestSupport.scope(), Optional.empty(), true); + ordinary(executor.execute(new ServerControlOperation.CreateRoleAssignment(assignment), "operator-a", + resource(Permission.ResourceType.ROLE, ServerTestSupport.scope()), Optional.empty(), + CancellationSignal.NONE)); + Permission.Grant deny = ServerTestSupport.grant("deny-a", principal.principalId(), + Permission.Effect.DENY, Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, + ServerTestSupport.scope(), Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED); + ordinary(executor.execute(new ServerControlOperation.CreateGrant(deny), "operator-a", + resource(Permission.ResourceType.GRANT, ServerTestSupport.scope()), Optional.empty(), + CancellationSignal.NONE)); + assertEquals(1, opened.store().principals(0, 10).values().size()); + assertEquals(1, opened.store().assignments(0, 10).values().size()); + assertEquals(Permission.Effect.DENY, opened.store().requireGrant("deny-a").effect()); + System.out.println("...persisted=principal,assignment,deny-grant"); + } + System.out.println("...ok"); + } + + @Test + void approvalRequestUsesConfiguredPolicyAndDoesNotExecuteTarget() throws Exception { + System.out.println("approvalRequestUsesConfiguredPolicyAndDoesNotExecuteTarget"); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { + opened.store().createPrincipal(ServerTestSupport.principal("requester-a")); + opened.store().createPrincipal(ServerTestSupport.principal("approver-a")); + ServerControlOperationExecutor executor = executor(opened); + PkiOperation target = new PkiOperation.CreateAuthority(new FormatId("x509"), + new SubjectRef("subject-a"), "profile-a", new KeyRef("key-a")); + Permission.Resource targetResource = resource(Permission.ResourceType.AUTHORITY, realmScope()); + ServerControlOperation.RequestApproval request = new ServerControlOperation.RequestApproval( + "approval-a", new AdministrativeOperation.Pki(target), targetResource); + ordinary(executor.execute(request, "requester-a", resource(Permission.ResourceType.APPROVAL, + realmScope()), Optional.empty(), CancellationSignal.NONE)); + ApprovalService.Request persisted = opened.store().requireApproval("approval-a"); + assertEquals(ApprovalService.State.PENDING, persisted.state()); + assertEquals("high-risk", persisted.policy().policyId()); + System.out.println("...target-state=PENDING,no-execution"); + } + System.out.println("...ok"); + } + + @Test + void capabilityResultIsSingleConsumptionAndPersistenceContainsOnlyCommitment() throws Exception { + System.out.println("capabilityResultIsSingleConsumptionAndPersistenceContainsOnlyCommitment"); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { + opened.store().createPrincipal(ServerTestSupport.principal("security-admin")); + PkiId objectId = new PkiId("credential-a"); + DisclosureService disclosure = disclosure(opened); + disclosure.register(objectId, DisclosureService.ObjectType.LEAF_CERTIFICATE, + DisclosureService.Policy.PUBLIC_UNLISTED, Optional.of("security-admin"), + ServerTestSupport.DIGEST, "security-admin"); + ServerControlOperationExecutor executor = executor(opened, disclosure); + ServerControlOperationOutcome outcome = executor.execute(new ServerControlOperation.IssueCapability( + objectId, ServerTestSupport.CLOCK.instant().plusSeconds(600)), "security-admin", + resource(Permission.ResourceType.CAPABILITY, realmScope()), Optional.empty(), + CancellationSignal.NONE); + ServerControlOperationOutcome.OneTimeSensitive sensitive = assertInstanceOf( + ServerControlOperationOutcome.OneTimeSensitive.class, outcome); + byte[] token = sensitive.consumeToken(); + assertEquals(32, token.length); + assertThrows(IllegalStateException.class, sensitive::consumeToken); + String capabilityId = ((zeroecho.pki.application.PkiOperationValue.Text) + sensitive.safeResult().fields().get("capabilityId")).value(); + assertEquals(32, opened.store().requireCapability(capabilityId).tokenCommitment().length); + assertEquals(DisclosureService.DeliveryState.PENDING, + opened.store().requireCapability(capabilityId).deliveryState()); + assertEquals(DisclosureService.DeliveryState.DELIVERY_UNKNOWN, + disclosure.classifyDelivery(capabilityId, false).deliveryState()); + System.out.println("...token-bytes=32,persisted=commitment-only"); + } + System.out.println("...ok"); + } + + @Test + void pendingBreakGlassRequiresSeparateActivationAndExpires() throws Exception { + System.out.println("pendingBreakGlassRequiresSeparateActivationAndExpires"); + try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) { + opened.store().createPrincipal(ServerTestSupport.principal("officer-a")); + opened.store().createPrincipal(ServerTestSupport.principal("security-admin")); + Permission.Grant grant = ServerTestSupport.grant("emergency-a", "officer-a", Permission.Effect.ALLOW, + Permission.Action.CERTIFICATE_REVOKE, Permission.ResourceType.REVOCATION, + ServerTestSupport.scope(), Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED); + BreakGlassService service = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()); + ApprovalService.Policy policy = new ApprovalService.Policy("emergency-policy", 1, + Set.of("security-admin"), Set.of(), true, Duration.ofHours(1), true); + ApprovalService approvals = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()); + ApprovalService.Request createApproval = approvals.request("approval-create", + ServerControlOperation.CreateBreakGlass.CREATE, ServerTestSupport.DIGEST, + ServerTestSupport.scope(), "officer-a", policy); + approvals.decide(createApproval.approvalId(), "security-admin", ApprovalService.Choice.APPROVE, + "creation independently reviewed"); + approvals.claim(createApproval.approvalId(), ServerControlOperation.CreateBreakGlass.CREATE, + ServerTestSupport.DIGEST, ServerTestSupport.scope()); + BreakGlassService.Record pending = service.create("break-a", "officer-a", grant, "incident-response", + "security-admin", Optional.of(createApproval.approvalId()), + ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1))); + assertEquals(BreakGlassService.State.PENDING, pending.state()); + assertTrue(service.activeFor("officer-a").grants().isEmpty()); + ApprovalService.Request activateApproval = approvals.request("approval-activate", + ServerControlOperation.ActivateBreakGlass.ACTIVATE, ServerTestSupport.DIGEST, + ServerTestSupport.scope(), "officer-a", policy); + approvals.decide(activateApproval.approvalId(), "security-admin", ApprovalService.Choice.APPROVE, + "activation independently reviewed"); + approvals.claim(activateApproval.approvalId(), ServerControlOperation.ActivateBreakGlass.ACTIVATE, + ServerTestSupport.DIGEST, ServerTestSupport.scope()); + assertEquals(BreakGlassService.State.ACTIVE, service.activatePending("break-a", "security-admin", + Optional.of(activateApproval.approvalId())).state()); + System.out.println("...states=PENDING,ACTIVE"); + } + System.out.println("...ok"); + } + + private ServerControlOperationExecutor executor(ServerTestSupport.OpenedStore opened) throws Exception { + return executor(opened, disclosure(opened)); + } + + private ServerControlOperationExecutor executor(ServerTestSupport.OpenedStore opened, + DisclosureService disclosure) { + ApprovalService.Policy policy = new ApprovalService.Policy("high-risk", 1, Set.of("approver-a"), + Set.of(), true, Duration.ofHours(1), true); + OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors(); + return new ServerControlOperationExecutor(ServerTestSupport.REALM, + new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true), + opened.store(), RoleTemplateCatalog.load(getClass().getClassLoader()), + new AuthorizationEngine(ServerTestSupport.CLOCK), + new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), + new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit()), disclosure, + new AuditorViews(ServerTestSupport.CLOCK, opened.audit()), descriptors, + Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy)); + } + + private DisclosureService disclosure(ServerTestSupport.OpenedStore opened) throws Exception { + SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); + random.setSeed(new byte[] { 1, 2, 3, 4 }); + return new DisclosureService(ServerTestSupport.REALM, opened.store(), + DisclosureService.Defaults.recommended(), ServerTestSupport.CLOCK, random, opened.audit()); + } + + private static Permission.Scope realmScope() { + return new Permission.Scope(ServerTestSupport.REALM, Optional.empty(), Optional.empty(), Optional.empty()); + } + + private static Permission.Resource resource(Permission.ResourceType type, Permission.Scope scope) { + return new Permission.Resource(type, scope, Optional.empty(), Optional.empty()); + } + + private static void ordinary(ServerControlOperationOutcome outcome) { + ServerControlOperationOutcome.Ordinary ordinary = assertInstanceOf( + ServerControlOperationOutcome.Ordinary.class, outcome); + assertInstanceOf(zeroecho.pki.application.PkiOperationOutcome.Success.class, ordinary.outcome()); + } +} diff --git a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java index 5f83893..8205974 100644 --- a/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java +++ b/pki-server/src/test/java/zeroecho/pki/server/ServerOperationGatewayTest.java @@ -184,7 +184,9 @@ class ServerOperationGatewayTest { void descriptorRegistryRejectsUnknownAndDuplicateOperations() { System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations"); OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors(); - assertEquals(16, descriptors.descriptors().size()); + assertEquals(50, descriptors.descriptors().size()); + assertEquals(16, descriptors.descriptors().values().stream() + .filter(value -> value.family() == OperationSecurityDescriptors.Family.PKI_OPERATION).count()); assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration())); OperationSecurityDescriptors.Descriptor descriptor = descriptors.descriptors() .get(PkiOperation.InspectAuthority.NAME); diff --git a/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java new file mode 100644 index 0000000..02385ad --- /dev/null +++ b/pki-server/src/test/java/zeroecho/pki/server/http/HttpServerControlOperationCodecTest.java @@ -0,0 +1,98 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.server.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.server.AdministrativeOperation; +import zeroecho.pki.server.OperationSecurityDescriptors; +import zeroecho.pki.server.RealmId; +import zeroecho.pki.server.ServerControlOperation; + +/** Strict HTTPS decoding coverage for the closed server-control operation family. */ +class HttpServerControlOperationCodecTest { + private static final RealmId REALM = new RealmId("production"); + private static final OperationSecurityDescriptors DESCRIPTORS = new OperationSecurityDescriptors(); + + @Test + void decodesPrincipalRegistrationIntoClosedControlType() { + System.out.println("decodesPrincipalRegistrationIntoClosedControlType"); + String json = "{\"version\":1,\"arguments\":{\"principalId\":\"operator-a\"," + + "\"type\":\"USER\",\"displayName\":\"Operator A\",\"enabled\":true}}"; + HttpOperationCodec.Decoded decoded = decode(ServerControlOperation.RegisterPrincipal.NAME, json); + AdministrativeOperation.Control family = assertInstanceOf(AdministrativeOperation.Control.class, + decoded.operation()); + ServerControlOperation.RegisterPrincipal operation = assertInstanceOf( + ServerControlOperation.RegisterPrincipal.class, family.operation()); + assertEquals("operator-a", operation.principal().principalId()); + System.out.println("...family=SERVER_CONTROL_OPERATION"); + System.out.println("...ok"); + } + + @Test + void rejectsUnknownControlArgumentBeforeGatewayInvocation() { + System.out.println("rejectsUnknownControlArgumentBeforeGatewayInvocation"); + String json = "{\"version\":1,\"arguments\":{\"principalId\":\"operator-a\"," + + "\"unexpected\":true}}"; + assertThrows(IllegalArgumentException.class, + () -> decode(ServerControlOperation.InspectPrincipal.NAME, json)); + System.out.println("...unknown-field=rejected"); + System.out.println("...ok"); + } + + @Test + void capabilityIssuanceDecodesWithoutBinaryOrTokenInput() { + System.out.println("capabilityIssuanceDecodesWithoutBinaryOrTokenInput"); + String json = "{\"version\":1,\"arguments\":{\"objectId\":\"credential-a\"," + + "\"expiresAt\":\"2026-08-04T12:10:00Z\"}}"; + HttpOperationCodec.Decoded decoded = decode(ServerControlOperation.IssueCapability.NAME, json); + ServerControlOperation.IssueCapability operation = assertInstanceOf( + ServerControlOperation.IssueCapability.class, + ((AdministrativeOperation.Control) decoded.operation()).operation()); + assertEquals("credential-a", operation.objectId().value()); + System.out.println("...request-token-fields=0"); + System.out.println("...ok"); + } + + private static HttpOperationCodec.Decoded decode(String operation, String json) { + return HttpOperationCodec.decode(operation, json.getBytes(StandardCharsets.UTF_8), 16_384, + REALM, DESCRIPTORS::require); + } +}