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 *       &mdash; 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&lt;String,Object&gt; 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}