001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.views; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.time.*; 022import java.util.*; 023 024/** 025 * The stock "record the minted key" store for row-bound job keys: issues an {@link IdempotencyKey} bound to 026 * {@code (action, targetId[, principal])}, and verifies a submitted key value against that recorded binding. 027 * 028 * <p> 029 * {@link IdempotencyKey} only <i>binds</i>; its consumer records what it minted. This ledger is that record for the 030 * row-job case: a key endpoint calls {@link #issue(String, String, String)} and answers {@link #response(IdempotencyKey)}, 031 * and the submit endpoint calls {@link #verify(String, String, String, String)} (usually through 032 * {@link RowJobSubmit#verify(JobKeyLedger, String, String)}). 033 * 034 * <h5 class='section'>Rules</h5> 035 * <ul class='spaced-list'> 036 * <li><b>One refusal for every miss.</b> Unknown, expired, evicted and mismatched keys all verify empty, and the 037 * caller answers all of them with {@link #REFUSAL_CODE}, so the answer is no oracle for "this value exists for 038 * another row". 039 * <li><b>Spent after first use.</b> On the first successful {@link AsyncJobRegistry#tryCreate(IdempotencyKey)} the 040 * endpoint calls {@link #claim(IdempotencyKey, String)}. A later verify returns the {@link Grant} with that job 041 * id, and the endpoint replays the job's ref while the registry still has it, or answers {@link #SPENT_CODE} 042 * — never a second {@code tryCreate}, so a replay after the registry's retention cannot start a second job. 043 * <li><b>Bounded per principal.</b> A principal holds at most {@code maxPerPrincipal} unspent keys per action; one 044 * more evicts that principal's oldest. {@code maxEntries} is a global backstop (oldest first). Without auth, 045 * every caller is one shared principal. 046 * <li><b>Same actor on both endpoints.</b> Resolve the principal the same way on the key endpoint and on submit, or 047 * verify fails. 048 * </ul> 049 * 050 * <p> 051 * In-memory and single-process, like {@link AsyncJobRegistry}. Safe for concurrent use. 052 * 053 * <h5 class='section'>Example:</h5> 054 * <p class='bjava'> 055 * <jk>private final</jk> JobKeyLedger <jv>jobKeys</jv> = JobKeyLedger.<jsm>create</jsm>(); 056 * 057 * <ja>@RestPost</ja>(path=<js>"/species/{id}/key"</js>) 058 * <jk>public</jk> Map<String,Object> key(RestRequest <jv>req</jv>, <ja>@Path</ja>(<js>"id"</js>) String <jv>id</jv>) { 059 * <jk>return</jk> JobKeyLedger.<jsm>response</jsm>(<jv>jobKeys</jv>.issue(<js>"restock-species"</js>, <jv>id</jv>, actor(<jv>req</jv>))); 060 * } 061 * </p> 062 * 063 * <h5 class='section'>See Also:</h5> 064 * <ul> 065 * <li class='jc'>{@link IdempotencyKey} 066 * <li class='jc'>{@link RowJobSubmit} 067 * <li class='jc'>{@link AsyncJobRegistry} 068 * </ul> 069 * 070 * @since 10.0.0 071 */ 072public final class JobKeyLedger { 073 074 /** The refusal code for every failed verify; the code {@link IdempotencyKey}'s Javadoc already uses. */ 075 public static final String REFUSAL_CODE = "write-guard:idempotency-binding-mismatch"; 076 077 /** The refusal code for a key that already started a job that is no longer in the registry. */ 078 public static final String SPENT_CODE = "write-guard:idempotency-key-spent"; 079 080 /** How long an issued key stays verifiable by default. */ 081 public static final Duration DEFAULT_TTL = Duration.ofMinutes(30); 082 083 /** The global entry cap by default. */ 084 public static final int DEFAULT_MAX_ENTRIES = 10_000; 085 086 /** The per-principal, per-action cap on unspent keys by default. */ 087 public static final int DEFAULT_MAX_PER_PRINCIPAL = 64; 088 089 /** The contract version of {@link #response(IdempotencyKey)}. */ 090 public static final String CONTRACT_VERSION = "1"; 091 092 /** 093 * A successful verify. 094 * 095 * @param key The rehydrated key, with its recorded binding. 096 * @param jobId The job this key already started, if {@link #claim(IdempotencyKey, String) claimed}. 097 */ 098 public record Grant(IdempotencyKey key, Optional<String> jobId) {} 099 100 private record Entry(String action, String targetId, String principal, Instant expiresAt, String jobId) { 101 Entry claimed(String id) { return new Entry(action, targetId, principal, expiresAt, id); } 102 } 103 104 private Duration ttl = DEFAULT_TTL; 105 private int maxEntries = DEFAULT_MAX_ENTRIES; 106 private int maxPerPrincipal = DEFAULT_MAX_PER_PRINCIPAL; 107 private Clock clock = Clock.systemUTC(); 108 109 /** Value to entry, in access order: the eldest is the least-recently used. */ 110 private final LinkedHashMap<String,Entry> entries = new LinkedHashMap<>(16, 0.75f, true); 111 112 private JobKeyLedger() {} 113 114 /** 115 * Creates a ledger with the default limits. 116 * 117 * @return A new ledger. 118 */ 119 public static JobKeyLedger create() { 120 return new JobKeyLedger(); 121 } 122 123 /** 124 * @param value How long an issued key stays verifiable. Must be positive. 125 * @return This object. 126 */ 127 public synchronized JobKeyLedger ttl(Duration value) { 128 if (value == null || value.isNegative() || value.isZero()) 129 throw iaex("JobKeyLedger ttl must be positive."); 130 ttl = value; 131 return this; 132 } 133 134 /** 135 * @param value The global entry cap. Must be positive. 136 * @return This object. 137 */ 138 public synchronized JobKeyLedger maxEntries(int value) { 139 if (value < 1) 140 throw iaex("JobKeyLedger maxEntries must be positive."); 141 maxEntries = value; 142 return this; 143 } 144 145 /** 146 * @param value The per-principal, per-action cap on unspent keys. Must be positive. 147 * @return This object. 148 */ 149 public synchronized JobKeyLedger maxPerPrincipal(int value) { 150 if (value < 1) 151 throw iaex("JobKeyLedger maxPerPrincipal must be positive."); 152 maxPerPrincipal = value; 153 return this; 154 } 155 156 /** 157 * @param value The clock, for tests. 158 * @return This object. 159 */ 160 public synchronized JobKeyLedger clock(Clock value) { 161 clock = reqnn("clock", value); 162 return this; 163 } 164 165 /** 166 * Mints and records a key bound to {@code (action, targetId)} with no principal. 167 * 168 * @param action The action id. Must not be <jk>null</jk> or blank. 169 * @param targetId The row id. Must not be <jk>null</jk> or blank. 170 * @return The new key. 171 * @throws IllegalArgumentException If an argument is <jk>null</jk> or blank. 172 */ 173 public IdempotencyKey issue(String action, String targetId) { 174 return recordKey(action, targetId, null); 175 } 176 177 /** 178 * Mints and records a key bound to {@code (action, targetId)} and to the actor. 179 * 180 * @param action The action id. Must not be <jk>null</jk> or blank. 181 * @param targetId The row id. Must not be <jk>null</jk> or blank. 182 * @param principal The actor. Must not be <jk>null</jk> or blank. 183 * @return The new key. 184 * @throws IllegalArgumentException If an argument is <jk>null</jk> or blank. 185 */ 186 public IdempotencyKey issue(String action, String targetId, String principal) { 187 if (ib(principal)) 188 throw iaex("Argument 'principal' must not be blank."); 189 return recordKey(action, targetId, principal); 190 } 191 192 private synchronized IdempotencyKey recordKey(String action, String targetId, String principal) { 193 var key = IdempotencyKey.mint(action, targetId); 194 var now = clock.instant(); 195 sweep(now); 196 capPrincipal(action, principal); 197 entries.put(key.value(), new Entry(action, targetId, principal, now.plus(ttl), null)); 198 while (entries.size() > maxEntries) 199 removeEldest(); 200 return key; 201 } 202 203 /** Evicts this principal's oldest unspent keys for this action until one more fits under the cap. */ 204 private void capPrincipal(String action, String principal) { 205 var mine = new ArrayList<String>(); 206 for (var e : entries.entrySet()) { 207 var v = e.getValue(); 208 if (v.jobId() == null && action.equals(v.action()) && eq(principal, v.principal())) 209 mine.add(e.getKey()); 210 } 211 // Access order puts the least-recently-used first. 212 for (var i = 0; i <= mine.size() - maxPerPrincipal; i++) 213 entries.remove(mine.get(i)); 214 } 215 216 private void removeEldest() { 217 var it = entries.entrySet().iterator(); 218 it.next(); 219 it.remove(); 220 } 221 222 private void sweep(Instant now) { 223 entries.values().removeIf(e -> ! now.isBefore(e.expiresAt())); 224 } 225 226 /** 227 * Verifies a submitted key against a binding with no principal. 228 * 229 * @param value The submitted key value. Can be <jk>null</jk>. 230 * @param action The action the submit is for. Can be <jk>null</jk>. 231 * @param targetId The row the submit is for. Can be <jk>null</jk>. 232 * @return The grant, or empty for every kind of miss. 233 */ 234 public Optional<Grant> verify(String value, String action, String targetId) { 235 return check(value, action, targetId, null); 236 } 237 238 /** 239 * Verifies a submitted key against a binding and an actor. 240 * 241 * @param value The submitted key value. Can be <jk>null</jk>. 242 * @param action The action the submit is for. Can be <jk>null</jk>. 243 * @param targetId The row the submit is for. Can be <jk>null</jk>. 244 * @param principal The actor. Can be <jk>null</jk> (then only a key issued without a principal verifies). 245 * @return The grant, or empty for every kind of miss. 246 */ 247 public Optional<Grant> verify(String value, String action, String targetId, String principal) { 248 return check(value, action, targetId, principal); 249 } 250 251 private synchronized Optional<Grant> check(String value, String action, String targetId, String principal) { 252 if (isBlank(value) || isBlank(action) || isBlank(targetId)) 253 return Optional.empty(); 254 sweep(clock.instant()); 255 var e = entries.get(value); 256 if (e == null) 257 return Optional.empty(); 258 var key = IdempotencyKey.of(value, e.action(), e.targetId()); 259 if (! key.matches(action, targetId) || neq(e.principal(), principal)) 260 return Optional.empty(); 261 return Optional.of(new Grant(key, Optional.ofNullable(e.jobId()))); 262 } 263 264 private static boolean isBlank(String s) { 265 return ib(s); 266 } 267 268 /** 269 * Records the job a key started, so a later verify carries it and the key can never start a second one. 270 * 271 * <p> 272 * Idempotent for the same job id. A key that is no longer recorded is ignored. 273 * 274 * @param key The verified key. Must not be <jk>null</jk>. 275 * @param jobId The job it started. Must not be <jk>null</jk> or blank. 276 * @throws IllegalArgumentException If an argument is <jk>null</jk> or blank. 277 * @throws IllegalStateException If the key already started a different job. 278 */ 279 public synchronized void claim(IdempotencyKey key, String jobId) { 280 reqnn("key", key); 281 if (isBlank(jobId)) 282 throw iaex("Argument 'jobId' must not be blank."); 283 var e = entries.get(key.value()); 284 if (e == null) 285 return; 286 if (e.jobId() != null && ! e.jobId().equals(jobId)) 287 throw new IllegalStateException("JobKeyLedger key already claimed by another job."); 288 entries.put(key.value(), e.claimed(jobId)); 289 } 290 291 /** 292 * The number of recorded keys, for tests and diagnostics. 293 * 294 * @return The entry count after a sweep. 295 */ 296 public synchronized int size() { 297 sweep(clock.instant()); 298 return entries.size(); 299 } 300 301 /** 302 * The key endpoint's response body: {@code {contractVersion, action, targetId, idempotencyKey}}. 303 * 304 * @param key The issued key. Must not be <jk>null</jk>. 305 * @return An ordered map. 306 */ 307 public static Map<String,Object> response(IdempotencyKey key) { 308 reqnn("key", key); 309 return m( 310 "contractVersion", CONTRACT_VERSION, 311 "action", key.action(), 312 "targetId", key.targetId(), 313 "idempotencyKey", key.value() 314 ); 315 } 316 317 @Override /* Object */ 318 public synchronized String toString() { 319 return "JobKeyLedger(size=" + entries.size() + ")"; 320 } 321}