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.util.*; 022 023import org.apache.juneau.http.response.*; 024import org.apache.juneau.marshall.collections.*; 025 026/** 027 * The action-envelope submit of a row job: {@code {action, targetId, idempotencyKey, fields}}, the shape the 028 * row-action dialog and the job-log card's {@code envelope: "action"} both send. 029 * 030 * <p> 031 * {@link #parse(JsonMap)} checks the shape; {@link #verify(JobKeyLedger, String, String)} checks the action, then the 032 * key's recorded binding. The row identity is {@link #targetId()} plus that binding — never anything in 033 * {@link #fields()}, which is user-tamperable request data. An endpoint that also carries the row id in its path 034 * should check {@code targetId().equals(pathId)} first. 035 * 036 * <h5 class='section'>Example:</h5> 037 * <p class='bjava'> 038 * <jk>var</jk> <jv>submit</jv> = RowJobSubmit.<jsm>parse</jsm>(<jv>json</jv>); 039 * <jk>if</jk> (! <jv>submit</jv>.targetId().equals(<jv>id</jv>)) 040 * <jk>return</jk> refuse(<jv>res</jv>, RowJobSubmit.<jsm>bindingRefusal</jsm>()); 041 * <jk>var</jk> <jv>grant</jv> = <jv>submit</jv>.verify(<jv>jobKeys</jv>, <js>"restock-species"</js>, <jv>actor</jv>); 042 * <jk>if</jk> (<jv>grant</jv>.isEmpty()) 043 * <jk>return</jk> refuse(<jv>res</jv>, RowJobSubmit.<jsm>bindingRefusal</jsm>()); <jc>// answered 409</jc> 044 * </p> 045 * 046 * <h5 class='section'>See Also:</h5> 047 * <ul> 048 * <li class='jc'>{@link JobKeyLedger} 049 * </ul> 050 * 051 * @param action The submitted action id. 052 * @param targetId The submitted row id. 053 * @param idempotencyKey The submitted key value. 054 * @param fields The submitted fields; empty when the body omitted them. 055 * 056 * @since 10.0.0 057 */ 058public record RowJobSubmit(String action, String targetId, String idempotencyKey, JsonMap fields) { 059 060 /** The HTTP status to answer {@link #bindingRefusal()} with. */ 061 public static final int REFUSAL_STATUS = 409; 062 063 /** 064 * Parses the envelope. 065 * 066 * @param body The request body. 067 * @return The submit. 068 * @throws BadRequest On a non-object body, a blank {@code action}, {@code targetId} or {@code idempotencyKey}, or 069 * a {@code fields} member that is not an object. A missing {@code fields} reads as empty. 070 */ 071 public static RowJobSubmit parse(JsonMap body) { 072 if (body == null) 073 throw new BadRequest("Expected a JSON object body"); 074 var action = text(body, "action"); 075 var targetId = text(body, "targetId"); 076 var key = text(body, "idempotencyKey"); 077 var f = body.get("fields"); 078 JsonMap fields; 079 if (f == null) 080 fields = new JsonMap(); 081 else if (f instanceof Map<?,?> m) 082 fields = new JsonMap(m); 083 else 084 throw new BadRequest("'fields' must be an object"); 085 return new RowJobSubmit(action, targetId, key, fields); 086 } 087 088 private static String text(JsonMap body, String name) { 089 var v = body.get(name); 090 if (! (v instanceof CharSequence cs) || cs.toString().isBlank()) 091 throw new BadRequest("Missing '%s'", name); 092 return cs.toString(); 093 } 094 095 /** 096 * Checks the action, then the key's binding to {@code (action, targetId)}. 097 * 098 * @param ledger The ledger the key was issued from. 099 * @param expectedAction The action this endpoint performs. 100 * @return The grant, or empty when the action differs or the key does not verify. 101 */ 102 public Optional<JobKeyLedger.Grant> verify(JobKeyLedger ledger, String expectedAction) { 103 if (neq(action, expectedAction)) 104 return Optional.empty(); 105 return ledger.verify(idempotencyKey, action, targetId); 106 } 107 108 /** 109 * Checks the action, then the key's binding to {@code (action, targetId, principal)}. 110 * 111 * @param ledger The ledger the key was issued from. 112 * @param expectedAction The action this endpoint performs. 113 * @param principal The actor, resolved the same way the key endpoint resolved it. 114 * @return The grant, or empty when the action differs or the key does not verify. 115 */ 116 public Optional<JobKeyLedger.Grant> verify(JobKeyLedger ledger, String expectedAction, String principal) { 117 if (neq(action, expectedAction)) 118 return Optional.empty(); 119 return ledger.verify(idempotencyKey, action, targetId, principal); 120 } 121 122 /** 123 * The standard refusal for a failed verify; answer it with {@link #REFUSAL_STATUS}. 124 * 125 * <p> 126 * One code and one message for every kind of miss (see {@link JobKeyLedger}). 127 * 128 * @return A new refusal. 129 */ 130 public static ActionResult bindingRefusal() { 131 return ActionResult.refusal(JobKeyLedger.REFUSAL_CODE).message("This key is not valid for this row."); 132 } 133 134 @Override /* Object */ 135 public String toString() { 136 return "RowJobSubmit(action=" + action + ",targetId=" + targetId + ",idempotencyKey=<redacted>,fields=" + fields + ")"; 137 } 138}