Operators read print jobs, read only. PrintingReadPermission accepted printing_operator for print stations and printers and required printing_admin for print jobs, so a person who prints could not see what was printed. A holder of printing_operator now has every read shape on printing_job that an administrator has (query, get, count, aggregate, export) and sees every job of the tenant, with the same fields; the table has no field for the document, its storage reference or the retained binding. Control history and deletion history stay administrator-only and nobody gains a write. Retire the Remove actions. Delete (RC.3) keeps job history and records who deleted what, so the older archive actions duplicated it on the screens. Removed: the processes printingRemoveDevices and printingRemoveSelectedPrinters and the control action Action.REMOVE. No HTTP or agent route called them; the REVOKE control still resolves to printingRevokeDevices. PrintingService.removePrinter and removeDevice stay as service methods, and records archived earlier behave as before. Refusals say what happened and what to do, and name no framework. The permission checks, the 403 text of the native and legacy routes, the control-action sign-in text and the OpenAPI descriptions are reworded; statuses, error codes and JSON field names are unchanged. A host may give its application's display name with PrintingQBitConfig.withProductName; the default is "this application". Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
311 lines
17 KiB
Java
311 lines
17 KiB
Java
/*
|
|
* QQQ - Low-code Application Framework for Engineers.
|
|
* Copyright (C) 2021-2025. Kingsrook, LLC
|
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
|
* contact@kingsrook.com
|
|
* https://github.com/Kingsrook/
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.kingsrook.qbits.printing;
|
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.time.Instant;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.util.Base64;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
|
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
|
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
|
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
|
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
|
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
|
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
|
import org.json.JSONObject;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
|
/*******************************************************************************
|
|
** What a holder of printing_operator, and a person with no printing permission,
|
|
** may read. Every read here asks for permission enforcement, as a request from
|
|
** a user does, against one database that holds the jobs of two tenants.
|
|
*******************************************************************************/
|
|
class PrintingOperatorReadTest
|
|
{
|
|
private static final Aggregate COUNT = new Aggregate("id", AggregateOperator.COUNT);
|
|
private static final List<String> EVERY_TABLE = List.of(PrintingTables.DEVICE, PrintingTables.PRINTER, PrintingTables.JOB, PrintingTables.COMMAND, PrintingTables.DELETION);
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** Each fixture owns its native context.
|
|
***************************************************************************/
|
|
@AfterEach
|
|
void clear()
|
|
{
|
|
QContext.clear();
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** An operator queries, gets, counts and groups the print jobs of their own
|
|
** tenant, whoever submitted them, and "what was printed today" is an
|
|
** ordinary date filter. Another tenant's jobs are in the same table and are
|
|
** never returned, counted or fetched.
|
|
***************************************************************************/
|
|
@Test
|
|
void operatorReadsCountsAndGroupsTheJobsOfTheirTenantOnly() throws Exception
|
|
{
|
|
var fixture = PrintingProcessesTest.fixture();
|
|
String mine = submit(fixture.service(), fixture.device());
|
|
String second = submit(fixture.service(), fixture.device());
|
|
String third = submit(fixture.service(), fixture.otherDevice());
|
|
PrintingService otherTenant = new PrintingService(source(fixture), "tenant-b");
|
|
String foreign = submit(otherTenant, fixture.foreignDevice());
|
|
submit(otherTenant, fixture.foreignDevice());
|
|
signIn(fixture, "tenant-a", "printing_operator");
|
|
|
|
List<QRecord> rows = new QueryAction().execute(enforced(new QueryInput(PrintingTables.JOB))).getRecords();
|
|
assertEquals(Set.of(mine, second, third), new HashSet<>(rows.stream().map(row -> row.getValueString("id")).toList()));
|
|
assertEquals(3, rows.size());
|
|
assertEquals(mine, get(PrintingTables.JOB, mine).getValueString("id"));
|
|
assertEquals(3, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount());
|
|
GroupBy station = new GroupBy(QFieldType.STRING, "device_id");
|
|
List<AggregateResult> byStation = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(COUNT).withGroupBy(station))).getResults();
|
|
assertEquals(Set.of(fixture.device() + "=2", fixture.otherDevice() + "=1"), new HashSet<>(byStation.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(COUNT)).toList()));
|
|
QQueryFilter today = new QQueryFilter().withCriteria("created_time", QCriteriaOperator.GREATER_THAN_OR_EQUALS, Instant.now().minus(1, ChronoUnit.DAYS));
|
|
assertEquals(3, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB).withFilter(today))).getCount());
|
|
new PrintingReadPermission().checkPermissionsThrowing(new ExportInput(), fixture.instance().getTable(PrintingTables.JOB));
|
|
|
|
assertNull(get(PrintingTables.JOB, foreign));
|
|
assertEquals(0, new QueryAction().execute(enforced(new QueryInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, foreign)))).getRecords().size());
|
|
assertEquals(0, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("tenant_id", QCriteriaOperator.EQUALS, "tenant-b")))).getCount());
|
|
|
|
signIn(fixture, "tenant-b", "printing_operator");
|
|
assertEquals(2, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount());
|
|
assertNull(get(PrintingTables.JOB, mine));
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** The job row an operator reads is the row an administrator reads, and it
|
|
** has no field for the document, where the document is stored, the retained
|
|
** binding or the accepted event. A new field on the table fails this test
|
|
** until somebody decides an operator may read it.
|
|
***************************************************************************/
|
|
@Test
|
|
void jobRowCarriesNoDocumentBindingOrCredential() throws Exception
|
|
{
|
|
var fixture = PrintingProcessesTest.fixture();
|
|
String job = submit(fixture.service(), fixture.device());
|
|
Set<String> reviewed = Set.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "content_length", "options_json", "idempotency_key", "created_at", "created_time",
|
|
"status", "message", "spooler_job_id", "device_name", "computer_name", "printer_name", "queue_name");
|
|
assertEquals(reviewed, fixture.instance().getTable(PrintingTables.JOB).getFields().keySet());
|
|
QRecord administrators = get(PrintingTables.JOB, job);
|
|
signIn(fixture, "tenant-a", "printing_operator");
|
|
QRecord operators = get(PrintingTables.JOB, job);
|
|
assertEquals(administrators.getValues(), operators.getValues());
|
|
assertTrue(reviewed.containsAll(operators.getValues().keySet()), operators.getValues().keySet().toString());
|
|
for(String withheld : List.of("content_base64", "content_reference", "content_storage_table", "content_binding", "retained_binding_json", "retained_actor_id", "accepted_event_id"))
|
|
{
|
|
assertFalse(operators.getValues().containsKey(withheld), withheld);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** Control history and deletion history stay with the administrator: an
|
|
** operator is refused every read shape on both.
|
|
***************************************************************************/
|
|
@Test
|
|
void operatorCannotReadControlOrDeletionHistory() throws Exception
|
|
{
|
|
var fixture = PrintingProcessesTest.fixture();
|
|
signIn(fixture, "tenant-a", "printing_operator");
|
|
for(String table : List.of(PrintingTables.COMMAND, PrintingTables.DELETION))
|
|
{
|
|
assertRefusedEveryRead(fixture, table);
|
|
}
|
|
for(String table : List.of(PrintingTables.DEVICE, PrintingTables.PRINTER, PrintingTables.JOB))
|
|
{
|
|
new QueryAction().execute(enforced(new QueryInput(table)));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** Reading is all the operator gained: no table write on any printing table,
|
|
** and none of the administrator's actions on a job, a printer or a station.
|
|
***************************************************************************/
|
|
@Test
|
|
void operatorCannotWriteOrRunAnAdministratorAction() throws Exception
|
|
{
|
|
var fixture = PrintingProcessesTest.fixture();
|
|
String job = submit(fixture.service(), fixture.device());
|
|
signIn(fixture, "tenant-a", "printing_operator");
|
|
PrintingReadPermission checker = new PrintingReadPermission();
|
|
for(String table : EVERY_TABLE)
|
|
{
|
|
var metadata = fixture.instance().getTable(table);
|
|
for(var write : List.of(new InsertInput(table), new UpdateInput(table), new DeleteInput(table), new RunBackendStepInput()))
|
|
{
|
|
assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(write, metadata), table);
|
|
}
|
|
}
|
|
InsertInput insert = new InsertInput(PrintingTables.JOB).withRecord(new QRecord().withValue("id", UUID.randomUUID().toString()).withValue("tenant_id", "tenant-a").withValue("title", "forged"));
|
|
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
|
assertThrows(QException.class, () -> new InsertAction().execute(insert));
|
|
for(String process : List.of(PrintingJobProcesses.RETRY, PrintingJobProcesses.CANCEL, PrintingDeviceProcesses.Action.CANCEL_WINDOWS.process, PrintingDeviceProcesses.Action.CLEAR.process,
|
|
PrintingDeviceProcesses.Action.REVOKE.process, "printingEnrollDevice", "printingEnableSelectedPrinters", PrintingDeleteProcesses.DEVICES, PrintingDeleteProcesses.PRINTERS))
|
|
{
|
|
assertThrows(QPermissionDeniedException.class, () -> PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(process), process), process);
|
|
}
|
|
assertThrows(QException.class, () -> PrintingControlAuthorization.job(fixture.device(), job));
|
|
assertThrows(QException.class, () -> PrintingControlAuthorization.principal());
|
|
signIn(fixture, "tenant-a", "printing_admin");
|
|
assertEquals("QUEUED", get(PrintingTables.JOB, job).getValueString("status"));
|
|
assertEquals(1, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount());
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** A signed-in person with neither printing permission is refused every read
|
|
** and every write on every printing table.
|
|
***************************************************************************/
|
|
@Test
|
|
void personWithNeitherPermissionIsRefusedEverything() throws Exception
|
|
{
|
|
var fixture = PrintingProcessesTest.fixture();
|
|
submit(fixture.service(), fixture.device());
|
|
signIn(fixture, "tenant-a");
|
|
PrintingReadPermission checker = new PrintingReadPermission();
|
|
for(String table : EVERY_TABLE)
|
|
{
|
|
assertRefusedEveryRead(fixture, table);
|
|
var metadata = fixture.instance().getTable(table);
|
|
for(var write : List.of(new InsertInput(table), new UpdateInput(table), new DeleteInput(table)))
|
|
{
|
|
assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(write, metadata), table);
|
|
}
|
|
}
|
|
assertThrows(QException.class, () -> PrintingOperatorAuthorization.catalog(50, null));
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** Query, get, count, aggregate and export are all refused on the table.
|
|
***************************************************************************/
|
|
private static void assertRefusedEveryRead(PrintingProcessesTest.Fixture fixture, String table)
|
|
{
|
|
assertThrows(QPermissionDeniedException.class, () -> new QueryAction().execute(enforced(new QueryInput(table))), table);
|
|
assertThrows(QPermissionDeniedException.class, () -> new GetAction().execute(enforced(new GetInput(table).withPrimaryKey("any"))), table);
|
|
assertThrows(QPermissionDeniedException.class, () -> new CountAction().execute(enforced(new CountInput(table))), table);
|
|
assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(table).withAggregate(COUNT))), table);
|
|
assertThrows(QPermissionDeniedException.class, () -> new PrintingReadPermission().checkPermissionsThrowing(new ExportInput(), fixture.instance().getTable(table)), table);
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** A new session for one person of one tenant holding exactly these permissions.
|
|
***************************************************************************/
|
|
private static void signIn(PrintingProcessesTest.Fixture fixture, String tenant, String... permissions)
|
|
{
|
|
QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("warehouse-person")).withPermissions(permissions).withSecurityKeyValue("printing_tenant", tenant));
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** The read asks for permission enforcement as a user's request does.
|
|
***************************************************************************/
|
|
private static <T extends AbstractTableActionInput> T enforced(T input)
|
|
{
|
|
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
|
return input;
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** Read one row through QQQ with the session's permissions and tenant.
|
|
***************************************************************************/
|
|
private static QRecord get(String table, String id) throws QException
|
|
{
|
|
return new GetAction().execute(enforced(new GetInput(table).withPrimaryKey(id))).getRecord();
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** The fixture's database as a data source, for another tenant's service on it.
|
|
***************************************************************************/
|
|
private static DriverDataSource source(PrintingProcessesTest.Fixture fixture)
|
|
{
|
|
var backend = (com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) fixture.instance().getBackends().values().iterator().next();
|
|
return new DriverDataSource(backend.getJdbcUrl(), "sa", "");
|
|
}
|
|
|
|
|
|
|
|
/***************************************************************************
|
|
** One synthetic PDF job for the station's "laser" queue; nothing is printed.
|
|
***************************************************************************/
|
|
private static String submit(PrintingService service, String station) throws Exception
|
|
{
|
|
return service.submit(new JSONObject().put("deviceId", station).put("printerId", "laser").put("title", "Operator read fixture")
|
|
.put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-1.4 fixture".getBytes(StandardCharsets.UTF_8)))
|
|
.put("idempotencyKey", UUID.randomUUID().toString())).getString("jobId");
|
|
}
|
|
}
|