/******************************************************************************* * 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 authorityId, Optional issuerId, Optional profileId, Optional 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 authorityId, Optional issuerId, Optional 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 objectId, Optional 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 conditions, Optional 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 reason, boolean approvalPresent, boolean authorityActive, Map 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; } }