Add per-responder signed-request policies, strict request-signature and requester-certificate validation, cryptographic principal mapping and scoped OCSP query authorization. Preserve public unsigned responder behavior while isolating requester, transport and administrative identities.
296 lines
14 KiB
Java
296 lines
14 KiB
Java
/*******************************************************************************
|
|
* Copyright (C) 2026, Leo Galambos
|
|
* All rights reserved.
|
|
*
|
|
* Redistribution and use in source and binary forms, with or without modification,
|
|
* are permitted provided that the following conditions are met:
|
|
*
|
|
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
* list of conditions and the following disclaimer.
|
|
*
|
|
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
* this list of conditions and the following disclaimer in the documentation
|
|
* and/or other materials provided with the distribution.
|
|
*
|
|
* 3. All advertising materials mentioning features or use of this software must
|
|
* display the following acknowledgement:
|
|
* This product includes software developed by the Egothor project.
|
|
*
|
|
* 4. Neither the name of the copyright holder nor the names of its contributors
|
|
* may be used to endorse or promote products derived from this software without
|
|
* specific prior written permission.
|
|
*
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
******************************************************************************/
|
|
package zeroecho.pki.server;
|
|
|
|
import java.time.Instant;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Optional;
|
|
import java.util.Set;
|
|
|
|
import zeroecho.pki.api.PkiId;
|
|
|
|
/** Closed permission vocabulary and immutable scoped grant model. */
|
|
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.ControlStatementBraces",
|
|
"PMD.AvoidLiteralsInIfCondition", "PMD.CommentDefaultAccessModifier", "PMD.LongVariable" })
|
|
public final class Permission {
|
|
private Permission() {
|
|
}
|
|
|
|
/** Stable permission actions; semantic separation is part of the public contract. */
|
|
public enum Action {
|
|
REALM_READ(1), SERVER_HEALTH_READ(2), SERVER_CONFIGURATION_READ(3),
|
|
SERVER_CONFIGURATION_UPDATE(4), IDENTITY_PROVIDER_MANAGE(5), PRINCIPAL_MANAGE(6),
|
|
ROLE_MANAGE(7), PERMISSION_GRANT(8), FORWARD_AUTHENTICATED_CLIENT_IDENTITY(9),
|
|
AUTHORITY_LIST(20), AUTHORITY_READ(21),
|
|
AUTHORITY_CREATE(22), AUTHORITY_IMPORT(23), AUTHORITY_ACTIVATE(24), AUTHORITY_SUSPEND(25),
|
|
AUTHORITY_RETIRE(26), ISSUER_CREATE(27), ISSUER_ROTATE(28), ISSUER_RETIRE(29),
|
|
CA_CHAIN_DOWNLOAD(30), PROFILE_READ(40), PROFILE_REGISTER(41), PROFILE_VALIDATE(42),
|
|
PROFILE_ACTIVATE(43), PROFILE_DEACTIVATE(44), POLICY_READ(45), POLICY_UPDATE(46),
|
|
X509_BINDING_READ(47), X509_BINDING_PROVIDER_ENABLE(48), REQUEST_SUBMIT(60),
|
|
REQUEST_READ_OWN(61), REQUEST_READ_ANY(62), REQUEST_APPROVE(63), REQUEST_REJECT(64),
|
|
REQUEST_CANCEL(65), CERTIFICATE_ISSUE(70), CERTIFICATE_RENEW(71), CERTIFICATE_REKEY(72),
|
|
CERTIFICATE_READ_METADATA(73), CERTIFICATE_READ_CONTENT(74), CERTIFICATE_SEARCH(75),
|
|
CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78),
|
|
CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
|
|
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
|
|
OCSP_ADMINISTER(87), OCSP_RESPONDER_READ(88), OCSP_STATUS_QUERY(89),
|
|
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),
|
|
DISCLOSURE_READ(160), DISCLOSURE_CHANGE(161), DISCLOSURE_CAPABILITY_ISSUE(162),
|
|
DISCLOSURE_CAPABILITY_REVOKE(163), REPOSITORY_ALIAS_READ(164), REPOSITORY_ALIAS_MANAGE(165),
|
|
ACME_DIRECTORY_READ(180), ACME_DIRECTORY_MANAGE(181), ACME_ACCOUNT_READ(182), ACME_ACCOUNT_MANAGE(183);
|
|
|
|
private final int code;
|
|
|
|
Action(int code) {
|
|
this.code = code;
|
|
}
|
|
|
|
/** @return stable persistence code */
|
|
public int code() {
|
|
return code;
|
|
}
|
|
|
|
/** Resolves a stable persistence code. */
|
|
public static Action fromCode(int code) {
|
|
for (Action candidate : values()) {
|
|
if (candidate.code == code) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new IllegalArgumentException("Unknown permission action code");
|
|
}
|
|
}
|
|
|
|
/** Grant effect. */
|
|
public enum Effect {
|
|
ALLOW(1), DENY(2);
|
|
private final int code;
|
|
Effect(int code) { this.code = code; }
|
|
/** @return stable code */ public int code() { return code; }
|
|
/** Resolves a stable code. */
|
|
public static Effect fromCode(int code) {
|
|
return switch (code) { case 1 -> ALLOW; case 2 -> DENY;
|
|
default -> throw new IllegalArgumentException("Unknown grant effect code"); };
|
|
}
|
|
}
|
|
|
|
/** Security resource types, distinct from PKI authority ownership. */
|
|
public enum ResourceType {
|
|
REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10),
|
|
ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21),
|
|
REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32),
|
|
DISCLOSURE(33), CAPABILITY(34), APPROVAL(35), BREAK_GLASS(36), REPOSITORY_ALIAS(37),
|
|
ACME_DIRECTORY(38), ACME_ACCOUNT(39), OCSP_RESPONDER(40);
|
|
private final int code;
|
|
ResourceType(int code) { this.code = code; }
|
|
/** @return stable code */ public int code() { return code; }
|
|
/** Resolves a stable code. */
|
|
public static ResourceType fromCode(int code) {
|
|
for (ResourceType candidate : values()) if (candidate.code == code) return candidate;
|
|
throw new IllegalArgumentException("Unknown resource type code");
|
|
}
|
|
}
|
|
|
|
/** Relationship between the requesting principal and the target object. */
|
|
public enum Relationship {
|
|
OWN(1), ANY(2);
|
|
private final int code;
|
|
Relationship(int code) { this.code = code; }
|
|
/** @return stable code */ public int code() { return code; }
|
|
/** Resolves a stable code. */
|
|
public static Relationship fromCode(int code) {
|
|
return switch (code) { case 1 -> OWN; case 2 -> ANY;
|
|
default -> throw new IllegalArgumentException("Unknown relationship code"); };
|
|
}
|
|
}
|
|
|
|
/** Explicitly separated disclosure and audit data views. */
|
|
public enum DataView {
|
|
METADATA_REDACTED(1), METADATA_FULL(2), CONTENT_FULL(3), PII_FULL(4);
|
|
private final int code;
|
|
DataView(int code) { this.code = code; }
|
|
/** @return stable code */ public int code() { return code; }
|
|
/** Resolves a stable code. */
|
|
public static DataView fromCode(int code) {
|
|
return switch (code) { case 1 -> METADATA_REDACTED; case 2 -> METADATA_FULL;
|
|
case 3 -> CONTENT_FULL; case 4 -> PII_FULL;
|
|
default -> throw new IllegalArgumentException("Unknown data-view code"); };
|
|
}
|
|
|
|
/** Tests view sufficiency without merging content and PII authority. */
|
|
public boolean permits(DataView requested) {
|
|
Objects.requireNonNull(requested, "requested");
|
|
return this == requested || this == METADATA_FULL && requested == METADATA_REDACTED;
|
|
}
|
|
}
|
|
|
|
/** Closed non-programmable grant conditions. */
|
|
public enum Condition {
|
|
REASON_REQUIRED(1), APPROVAL_REQUIRED(2), REQUESTER_APPROVER_SEPARATION(3),
|
|
AUTHORITY_ACTIVE(4), PROFILE_MATCH_REQUIRED(5);
|
|
private final int code;
|
|
Condition(int code) { this.code = code; }
|
|
/** @return stable code */ public int code() { return code; }
|
|
/** Resolves a stable code. */
|
|
public static Condition fromCode(int code) {
|
|
for (Condition candidate : values()) if (candidate.code == code) return candidate;
|
|
throw new IllegalArgumentException("Unknown condition code");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Canonical resource scope supplied independently of object contents.
|
|
*
|
|
* @param realmId realm identity
|
|
* @param authorityId explicit logical authority when applicable
|
|
* @param issuerId explicit issuer generation when applicable
|
|
* @param profileId explicit profile when applicable
|
|
*/
|
|
public record Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
|
|
Optional<String> profileId, Optional<String> responderId) {
|
|
/** Validates the exact finite scope. */
|
|
public Scope {
|
|
Objects.requireNonNull(realmId, "realmId");
|
|
authorityId = Objects.requireNonNull(authorityId, "authorityId");
|
|
issuerId = Objects.requireNonNull(issuerId, "issuerId");
|
|
profileId = Objects.requireNonNull(profileId, "profileId").map(Permission::requireProfile);
|
|
responderId = Objects.requireNonNull(responderId, "responderId")
|
|
.map(value -> { requireId(value, "OCSP responder"); return value; });
|
|
if (issuerId.isPresent() && authorityId.isEmpty()) {
|
|
throw new IllegalArgumentException("Issuer scope requires authority scope");
|
|
}
|
|
}
|
|
|
|
/** Creates a scope without an OCSP responder dimension. */
|
|
public Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
|
|
Optional<String> profileId) {
|
|
this(realmId, authorityId, issuerId, profileId, Optional.empty());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transport-neutral target reference. Possession is never authorization.
|
|
*
|
|
* @param type resource type
|
|
* @param scope exact security scope
|
|
* @param objectId optional canonical object identity
|
|
* @param ownerPrincipalId optional owning principal identity
|
|
*/
|
|
public record Resource(ResourceType type, Scope scope, Optional<PkiId> objectId,
|
|
Optional<String> ownerPrincipalId) {
|
|
/** Validates the finite resource reference. */
|
|
public Resource {
|
|
Objects.requireNonNull(type, "type");
|
|
Objects.requireNonNull(scope, "scope");
|
|
objectId = Objects.requireNonNull(objectId, "objectId");
|
|
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
|
|
.map(Permission::requirePrincipal);
|
|
}
|
|
|
|
/** @return whether the principal owns this object */
|
|
public boolean ownedBy(SecurityPrincipal principal) {
|
|
Objects.requireNonNull(principal, "principal");
|
|
return ownerPrincipalId.filter(principal.principalId()::equals).isPresent();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Immutable persisted permission grant.
|
|
*
|
|
* @param grantId stable grant identity
|
|
* @param principalId grantee identity
|
|
* @param effect allow or deny
|
|
* @param action exact action
|
|
* @param resourceType exact resource type
|
|
* @param scope exact non-wildcard realm and optional subscopes
|
|
* @param relationship own or any
|
|
* @param dataView maximum exact view
|
|
* @param conditions closed conditions
|
|
* @param expiresAt optional mandatory upper bound for temporary grants
|
|
* @param enabled whether the grant participates in decisions
|
|
*/
|
|
public record Grant(String grantId, String principalId, Effect effect, Action action,
|
|
ResourceType resourceType, Scope scope, Relationship relationship, DataView dataView,
|
|
Set<Condition> conditions, Optional<Instant> expiresAt, boolean enabled) {
|
|
/** Validates and snapshots the finite grant. */
|
|
public Grant {
|
|
requireId(grantId, "grant");
|
|
requirePrincipal(principalId);
|
|
Objects.requireNonNull(effect, "effect");
|
|
Objects.requireNonNull(action, "action");
|
|
Objects.requireNonNull(resourceType, "resourceType");
|
|
Objects.requireNonNull(scope, "scope");
|
|
Objects.requireNonNull(relationship, "relationship");
|
|
Objects.requireNonNull(dataView, "dataView");
|
|
conditions = Set.copyOf(Objects.requireNonNull(conditions, "conditions"));
|
|
expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
|
|
}
|
|
}
|
|
|
|
/** Safe finite context used only by closed conditions. */
|
|
public record Context(Optional<String> reason, boolean approvalPresent, boolean authorityActive,
|
|
Map<String, String> safeAttributes) {
|
|
/** Validates the finite condition context. */
|
|
public Context {
|
|
reason = Objects.requireNonNull(reason, "reason").map(value -> requireBounded(value, 1024, "reason"));
|
|
safeAttributes = Map.copyOf(Objects.requireNonNull(safeAttributes, "safeAttributes"));
|
|
if (safeAttributes.size() > 32) throw new IllegalArgumentException("Too many context attributes");
|
|
}
|
|
|
|
/** @return empty safe context */
|
|
public static Context empty() {
|
|
return new Context(Optional.empty(), false, false, Map.of());
|
|
}
|
|
}
|
|
|
|
static void requireId(String value, String label) {
|
|
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
|
|
throw new IllegalArgumentException(label + " identity is not canonical");
|
|
}
|
|
}
|
|
|
|
static String requirePrincipal(String value) { requireId(value, "principal"); return value; }
|
|
static String requireProfile(String value) { return requireBounded(value, 256, "profile"); }
|
|
static String requireBounded(String value, int limit, String label) {
|
|
if (value == null || value.isBlank() || value.length() > limit) {
|
|
throw new IllegalArgumentException(label + " is invalid");
|
|
}
|
|
return value;
|
|
}
|
|
}
|