feat(station): a print station authenticates as its own identity
maven / build (push) Successful in 7m38s
build / maven (push) Successful in 7m38s

A print station is a computer beside a printer with nobody signed in. Until
now it ran under the session of the person who paired it and renewed that
session every five minutes. A host that ends sessions after user inactivity
would sign it out 15 minutes after every sign-in, and a named person's
session running unattended is hard to defend.

- printing_station: a permission that alone opens nothing. On the device
  routes, after the request proves the one device bound to the station's
  identity, the station may heartbeat, report printers, claim, download and
  acknowledge its own jobs and answer control commands. The proof lives in
  the request's QContext, tied to that session object, never on the session.
  Every row read must belong to that device. Processes and the
  administrative, operator and generic table routes stay closed.
- printingEnrollStation creates a one-use, 15-minute station code.
  POST /qqq/v1/qbit/printing/stations/enroll redeems it with no session and
  answers once with the device proof and the identity's credential. A station
  code cannot pair a person and a person's code cannot create a station.
- PrintingStationIdentities (issue, revoke) is the host's issuer, set with
  PrintingService.withStationIdentities. Without it there is no station
  enrollment (503). An identity issued for an enrollment that then fails is
  revoked. Revoking or removing a station's device revokes its identity; a
  provider failure answers 502 and the action can be repeated.
- The realtime upgrade accepts exactly one credential: a session cookie, or,
  for a station only, a bearer token, verified again on every frame.
- Two additive nullable columns: printing_enrollment.station_name and
  printing_device.station_name. Extension 2.5.0; wire v1 and the subprotocol
  are unchanged, and existing pairings behave as before.

The claim and acknowledgement rules of realtime printing on several nodes are
untouched. docs/STATION-IDENTITY.md describes the flow, the guarantees, the
host contract, what the host, the identity QBit, the provider and the Windows
client still need, the release order and the first enrollment on dev.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-10 17:55:30 -06:00
1 parent 42dff1ff39
commit 254f6fd918
20 files changed
+1611 -54

No files matched your search

+25
View File
@@ -28,6 +28,31 @@ refused with 409.
application bus, and the claim, the acknowledgement and the connection record on
PostgreSQL 17 (Testcontainers; Docker required to run the suite).
Print stations (extension 2.5.0; wire v1 and the subprotocol are unchanged, and existing
connectors and pairings behave as before). A station is an unattended computer beside a
printer. It no longer has to run under the session of the person who paired it: it can
authenticate as its own identity, which the host's identity provider issues. See
[print station identity](docs/STATION-IDENTITY.md).
- New permission `printing_station`. Alone it opens nothing. On the device routes, after the
request proves the one device bound to the station's identity, the station may heartbeat,
report printers, claim, download and acknowledge its own jobs and answer control commands.
Every row it reads must belong to that device; processes, administrative, operator and
generic table routes stay closed to it.
- New process `printingEnrollStation` (printing administrators): a one-use, 15-minute station
enrollment code. New route `POST /qqq/v1/qbit/printing/stations/enroll`: the code is the
request's authority (no session); the answer carries the device proof and the identity's
credential once. A station code cannot pair a person, and a person's code cannot create a
station.
- The host supplies the identities through `PrintingService.withStationIdentities`
(`PrintingStationIdentities.issue` / `revoke`). Without it no station code can be created or
redeemed (503). Revoking or removing a station's device also revokes its identity; if the
provider cannot be reached the action answers 502 and can be repeated.
- The realtime route accepts exactly one credential: a session cookie, or, for a print
station only, an `Authorization: Bearer` token. The token is verified again on every frame.
- Two additive, nullable columns: `printing_enrollment.station_name` and
`printing_device.station_name`, added by `initialize()` or by the host's own migration.
## 1.1.2
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
+5
View File
@@ -66,6 +66,11 @@ binds the device to the core user's stable identity. A legacy unbound device can
be bound only through explicit `/device/bind` with valid proof and native device
access. Ordinary requests never bind or transfer ownership implicitly.
An unattended print station does not use a person's session. It enrolls with a one-use
station code at `/stations/enroll` and then authenticates as its own identity, which holds
`printing_station` and is confined to its own device on the device routes. The host issues
and revokes that identity. See [print station identity](STATION-IDENTITY.md).
`GET /session` returns the current principal and tenant; valid bound device headers
add the device identity. Native administration can manage another user's device
when current permissions allow it, without impersonating that user. Queue clear
+5
View File
@@ -22,6 +22,11 @@ and operation permissions; socket establishment does not grant permanent rights.
Session expiry, logout, revocation and permission denial require correction or
sign-in through the configured QQQ provider. Reconnect cannot bypass them.
A print station holds the connection with the bearer token of its own identity instead of a
session cookie (exactly one of the two is accepted, and a bearer token only for a station).
The token is verified again on every frame: when it expires the connection ends, and the
station reconnects with a new token. See [print station identity](STATION-IDENTITY.md).
Register the route provider alongside the ordinary native QQQ endpoints:
```java
+245
View File
@@ -0,0 +1,245 @@
# Print station identity
Status: proposed (draft pull request). Server side built and tested here; the host, identity and Windows client
pieces are specified in section 6 and are not built yet.
## 1. Problem
A print station is a computer beside a printer that nobody is signed in to. Until now it ran under the session of
the person who paired it and renewed that session every five minutes
(`PrintingAgentAuthorization`: "No independent authentication module or permanent service user"). That has two
problems:
- A host that ends sessions after user inactivity (QQQ `feat/session-user-activity`: 15 minutes without a real
user action, 12 hours at most) signs the station out 15 minutes after each sign-in. A station never has a user
action to report, so inactivity logoff cannot be switched on while stations use people's sessions.
- A named person's session running unattended around the clock is hard to defend under unique user
identification. Whatever the station does is recorded as that person.
A station now authenticates as its own identity: one identity per station, issued by the host's identity
provider, holding one narrow permission, bound to one device, and revocable alone.
## 2. How it works
1. A printing administrator runs **Enroll a print station** (process `printingEnrollStation`, on the Devices
table) and gives the facility, the computer's name and a station name such as `dock-3`. The result is a
one-use code, valid for 15 minutes. Only its hash is stored.
2. On the station, the print client is given the server address and that code. It calls
`POST /qqq/v1/qbit/printing/stations/enroll` with the code and nothing else: no person signs in on the station
and no person's password or session is ever on it.
3. The server checks the code, asks the host to create the station's identity
(`PrintingStationIdentities.issue`), creates the device bound to that identity, and answers once with two
secrets: the device token (this QBit's own proof of the device) and the identity's credential (what the
station presents to the identity provider). Neither is stored here in plaintext, and the identity's secret is
never stored here at all.
4. From then on the station asks the identity provider for a short access token with its credential, and sends
that token as `Authorization: Bearer` together with the device headers on every request and on the realtime
connection. The host turns the verified token into a session that holds `printing_station` and nothing else.
5. `printing_station` alone opens nothing. On the device routes, once the request has proved the one device
bound to this identity, the station may heartbeat, report its printers, claim, download and acknowledge its
own jobs, and receive and answer control commands. It cannot read any other device's rows, run any process,
use any administrative, operator or generic table route, enroll or bind a device, or submit, retry or cancel a
job.
6. Revoking the device (the existing **Revoke device access** and **Remove device** actions) revokes the device
here at once and then ends the identity at the provider (`PrintingStationIdentities.revoke`). If the provider
cannot be reached the administrator is told (502) and repeating the action repeats only that step.
7. Rotation is re-enrollment: revoke the device, create a new code, enter it on the station. The station keeps
its name; it gets a new device row and a new credential.
A person's pairing keeps working exactly as before, and both kinds of device can exist side by side. A station
code cannot pair a person, and a person's code cannot create a station.
## 3. What the QBit guarantees
| Rule | Where |
|---|---|
| A station is a session with `printing_station` and without `printing_admin`. An administrator who also holds the station permission is an administrator. | `PrintingStationAuthorization.isStation` |
| The permission is honored only inside a request that proved the station's device. The proof lives in the request's `QContext`, tied to that exact session object; it is never written to the session, so a cached session cannot carry it into another request. | `PrintingStationAuthorization.device`, `provedDevice` |
| The device proof requires the device token, a device enrolled as a station, and that device bound to this identity. A station never binds a device, and a person never takes over a station's device. | `PrintingService.authorizeStation` / `authorize` |
| Every row a station reads through the device routes must belong to its device. | `PrintingControlAuthorization.record` → `PrintingStationAuthorization.requireOwn` |
| No process, and therefore no test print, retry, cancel, enrollment or device action. | `PrintingControlAuthorization.process` |
| Generic table access stays closed: outside a device request `PrintingReadPermission` still requires the operator or administrator permission. Writes are refused for everyone. | `PrintingReadPermission` |
| One station name has one live device and one identity. A second enrollment of a live station is refused before anything is issued. | `PrintingService.enrollStation` |
| An identity issued for an enrollment that then fails is revoked, so no credential outlives a failed enrollment. | `PrintingService.enrollStation` |
| On the realtime route exactly one credential is accepted: a session cookie (a person) or a bearer token (a station only). The token is verified again on every frame, so the connection ends when the token expires or the identity is revoked; the station reconnects with a new token. | `PrintingRealtimeRoutes.validateUpgrade`, `requireStationForBearer`, `Channel.authorizeCurrent` |
| Without a host issuer there is no station enrollment: no code can be created and the route answers 503. | `PrintingService.stationName`, `enrollStation` |
The claim and acknowledgement rules of realtime printing on several nodes are unchanged: a station claims only
through a connection that has applied current state (`requireRealtimeReady`), one statement moves a job from
`QUEUED` to `CLAIMED`, an acknowledged job is never handed out again, and the connection record on the device
row is owned by the connection it names. `PrintingStationTest` runs a station through claim, content, `RECEIVED`
and `SPOOLED` over HTTP and the socket.
## 4. Why an enrollment code
| Option | The long-lived secret passes through | Person's credential on the station | Verdict |
|---|---|---|---|
| **Enrollment code exchanged for the credential (chosen)** | Nobody: it is created when the code is redeemed and goes only to the station, over TLS | None | One short-lived, one-use value to carry. Approval and audit sit with the administrator's action that creates the code. Revocation is one action. |
| Pre-shared secret (administrator creates the account, copies its key to the station) | The administrator's screen, clipboard and remote-desktop session | None | Works with today's identity screens, but a person has seen the station's key, and every rotation repeats the walk. |
| Browser sign-in for pairing, then a station credential | Nobody | A person's session exists on the station for a few minutes | Needs a person with administrator rights at each station, and leaves their provider session in the station's browser unless sign-out is perfect. |
| Client certificate (mutual TLS) | Nobody (key generated on the station) | None | Strongest binding, but needs a certificate authority, client-certificate pass-through at the load balancer and a renewal service. Revisit when a hardware-bound key is required. |
The device token already binds the identity to one device: a stolen identity secret alone cannot print, claim or
read (no device proof), and a stolen device token alone cannot either (no session).
## 5. Host contract
```java
service.withStationIdentities(new PrintingStationIdentities()
{
public Issued issue(String stationName) // create the identity; return what the device stores
public void revoke(String principalId) // end it; safe to repeat
});
```
- `Issued.principalId` must equal `QSession.getUser().getIdReference()` of a session authenticated with the
issued credential. The device is bound to it.
- The session must hold `printing_station` and the host's `printing_tenant` key, and should hold nothing else.
- `issue` for a name whose previous device was revoked must return a working, new credential.
- Under QQQ OAuth2 the station's requests are ordinary bearer requests
(`OAuth2AuthenticationModule.createSessionFromBearerToken`): they are never stored sessions, so the user
inactivity timeout (which applies to stored sessions resumed by `sessionUUID`) does not apply to them. A
station is bounded instead by its access token lifetime and by revocation.
- Schema, additive and nullable: `printing_enrollment.station_name VARCHAR(63)`,
`printing_device.station_name VARCHAR(63)`. A host that owns its schema (Liquibase) adds both.
## 6. What the other repositories need
Verified at QQQ 4.1.0-RC.29, qbit-authentik `develop` a3fa59e, qqq-print-agent `release/2.0` 7610dec, gg-forge
`develop` and greater-goods-authentik-cd `main` on 2026-11-10. Nothing below is built.
### QQQ core: no change required
- A bearer JWT is verified and becomes a stateless session
(`OAuth2AuthenticationModule.java:388`, `OAuth2BearerTokenVerifier.java:207`); it needs
`withBearerTokensEnabled(true)` (`OAuth2AuthenticationMetaData.java:772`).
- A machine token without `sub` is accepted only with `withBearerMachineTokenUsernameSubject(true)` (`:896`);
the subject is then `username:<preferred_username>` (`OAuth2AuthenticationModule.java:455-457`).
- The `service:` name space is reserved for principals the host itself runs (`ServiceIdentities.java:297-301`:
a request's session whose user starts with `service:` is refused), and service tokens are never accepted over
HTTP (`docs/SERVICE-IDENTITY.md`). A station is a remote device, so its principal is `station:<name>`, not
`service:<name>`. Audit shows it through `QUser.fullName` (`AuditAction.java:544-560`).
- Inactivity logoff (`feat/session-user-activity`) checks only the stored-session path
(`OAuth2AuthenticationModule` `isUserInactive`, inside the `sessionUUID` resume). No change is needed for
stations; a test should pin that a bearer session is never ended by it.
- Scheduled backups in Forge (`BackgroundOwnerSessions.java:38-71`, called from `BackupsService.java:129`) are the
case core's service principals were built for: `ServiceIdentities.runOnBehalfOf("scheduler", owner, ...)`
(`ServiceIdentities.java:264`) with a declared principal and its own Authentik account. That is separate work.
### qbit-authentik: a host-callable station account API
Today a service account is created only by a signed-in operator (`authentikCreateServiceAccount`,
`AuthentikTokens.java:555-596`: `POST /core/users/service_account/`, key shown once; needs
`authentik.tokens.manage` and `authentik.users.manage`, `AuthentikGuard.java:152-154`; rotation by
`POST /core/tokens/{id}/set_key/`, `:445-462`). Redeeming a code has no operator session, so the host needs:
```java
public final class AuthentikStationAccounts // beside AuthentikMaintenance
{
public static Issued issue(String apiBackendName, String username, String groupName, int expiresInDays, String reason)
public static void revoke(String apiBackendName, String username, String reason)
}
```
run as a declared service principal the way `AuthentikMaintenance.applyPolicy` does (`AuthentikMaintenance.java:73-97`),
with the same guard, audit row before the call, protected-group checks and compensation
(`AuthentikTokens.java:593-594`). Open question for its owner: `tokenTwoPersonApproval` gates operator-created
service accounts (`AuthentikTokens.java:208-215`); for stations the second approval belongs on creating the
enrollment code, which needs an approval request type the host can raise.
### gg-forge
- `PrintingHostService.java:28`: add `printing_station` to `PERMISSIONS`; `:57-63`: give the tenant key to a
session that holds it; `:36-42`: `withStationIdentities(...)` backed by the API above; master changelog: the
two columns in section 5.
- `ForgeAuthMetaData.java:123-129`: bearer tokens are on only when `OAUTH2_NATIVE_CLIENT_ID` is set; stations
need `withBearerTokensEnabled(true)` and `withBearerMachineTokenUsernameSubject(true)` on the one provider.
- `ForgeAuthenticationCustomizer.java:149-155` requires an `email` claim. A station token has none. Add one
branch: no email, `preferred_username` under the declared station prefix, and a policies claim that expands to
exactly `printing_station` → user `station:<name>`, full name `Print station <name>`. Anything else fails
closed as today.
- `policies/policy-print-station.json`:
`{"PolicySpec":"me-health-iam","PolicyVersion":"1.1.0","Statement":[{"Effect":"Allow","Permission":["printing_station"]}]}`,
its expansion file and catalog entry (`PlatformPolicyDocumentsTest`).
### greater-goods-authentik-cd (declared configuration)
- Group `gg-forge-<env>-print-station` carrying that policy (generated into `gg-forge-<env>-roles.yaml` from
Forge's vendored policies), and an application access binding for it (`gg-forge-dev.yaml:398-409` binds only
`gg-forge-<env>-users`).
- Provider `grant_types` gains `client_credentials` (`gg-forge-dev.yaml:336-382`); the validator and
`docs/NATIVE-CLIENT.md:20-24` forbid it today (`scripts/validate_blueprint.py:188,241`).
- The identity service account's role (`gg-forge-dev-identity.yaml:35-79`) has `add_user`, `view_token` and
`delete_token` but no right to create a token or set its key; creating and rotating station accounts needs
those added, limited as far as Authentik allows.
- Station accounts and their keys are records, not blueprint entries: Forge creates them. Nothing per station is
in git.
### qqq-print-agent (2.0)
- `enroll-station --server URL` (code from `QQQ_PRINT_ENROLLMENT_CODE` or standard input, as `enroll` today,
`CommandLine.cs:21`) and a wizard path that replaces the Sign in step: call `stations/enroll`, store the
result.
- Storage: the station credential beside the pairing in `%ProgramData%\QqqPrintAgent`, DPAPI under the service
account, the directory limited to SYSTEM and Administrators (`AgentStore.Authentication.cs:21,88`,
`MachineStateGuard.cs:22`), as the session file is today.
- Authentication: discover the provider's token endpoint from the server's authentication metadata
(`QqqSessionClient.OAuth.cs:99-128`), request a token with
`grant_type=client_credentials`, `client_id`, `username`, `password=<secret>`, `scope=openid profile email`,
renew it before it expires, and send `Authorization: Bearer` instead of the cookie in
`QqqSessionClient.BuildRequest` (`QqqSessionClient.cs:586-611`) and on the socket upgrade
(`QqqSessionClient.Realtime.cs:74-81`). The existing "token changed, reconnect the socket" path
(`AgentRuntime.Authentication.cs:224-235`) already does what the server expects.
- A 401 or 403 with a valid token means revoked: block printing and say "Enroll this station again"
(`BlockAuthorization`, `AgentRuntime.Authentication.cs:276-292`). A person's sign-in remains for pairing a
desk printer.
## 7. Release order
1. qbit-printing with this change (after the several-nodes change it builds on).
2. qbit-authentik with the station account API.
3. greater-goods-authentik-cd: group, grant type, role rights (dev first).
4. gg-forge: pins, policy document, wiring, customizer branch, changelog.
5. qqq-print-agent 2.0 with station enrollment (signed release).
6. Enroll stations; only then enable user inactivity logoff where print clients sign in.
Steps 1 to 4 change nothing for existing stations: they keep their person's session until they are enrolled as
stations.
## 8. Enrolling the first station on dev
Preconditions: steps 1 to 5 above are deployed to dev.
1. Forge dev, as a printing administrator: Printing → Devices → **Enroll a print station**. Facility, device
name, station name `dock-1`. Expect `Station enrollment code: <43 characters> (expires <time>)`.
2. On the station, an elevated prompt: set `QQQ_PRINT_ENROLLMENT_CODE` to the code and run
`qqq-print-agent enroll-station --server https://<forge dev>`. Expect `Enrolled as print station dock-1`.
Entering the code a second time must answer `Enrollment code is invalid or expired`.
3. Forge dev: Devices shows the new device with station name `dock-1`, bound user `station:dock-1`, connection
state connected. Authentik dev shows the service account `svc-gg-forge-dev-print-dock-1`, active, in
`gg-forge-dev-print-station` only, with one expiring app password.
4. Approve its printers as usual (**Approve printers**), then **Print a test label** from Forge. Expect the job
to reach `SPOOLED`, and the audit to name `Print station dock-1`, not a person.
5. Negative checks with the station's token (from a test client, not the station): `GET /qqq/v1/metaData`
lists no table or process; `POST /qqq/v1/table/printing_job/query` is refused; a device route with another
device's headers is refused.
6. **Revoke device access** on the device. Expect the station to stop within one access-token lifetime at the
latest (immediately on its next request), the Authentik account to be deactivated, and the client to show
"Enroll this station again".
## 9. Proving inactivity logoff no longer affects a station
With `userInactivityTimeoutSeconds` set on the host (dev: 900):
1. Enroll a station and leave it untouched for longer than the timeout plus one access-token lifetime, and
longer than the absolute maximum (12 hours) for the full proof.
2. Submit a job at the end of that period. It must print without anyone touching the station.
3. The host's `userSession` and `userSessionActivity` tables must hold no row for the station's identity at any
time: it never has a stored session to end.
4. Control: a person's paired client on the same host, left untouched, is signed out after the timeout and
stops printing.
Automated form, in the host's tests: with the timeout set to a few seconds, a stored session that states no
user activity is refused after the timeout, while bearer requests with the device proof keep being accepted
after it. In this QBit, `PrintingStationTest.stationEnrollsAndPrintsOverHttpAndRealtimeWithItsOwnCredential`
shows the station working with no cookie at any point.
@@ -36,14 +36,17 @@ import org.json.JSONArray;
import org.json.JSONObject;
/** Secondary proof binds a device to the already authenticated native QQQ principal. */
/**
* Secondary proof binds a device to the already authenticated native QQQ principal: the person who paired it, or the print
* station identity it enrolled as (PrintingStationAuthorization).
*/
public final class PrintingAgentAuthorization
{
public static final String DEVICE_HEADER = "X-QQQ-Device-Id";
public static final String TOKEN_HEADER = "X-QQQ-Device-Token";
/** No independent authentication module or permanent service user. */
/** No independent authentication module: a person or a station identity is always authenticated by the host first. */
private PrintingAgentAuthorization()
{
}
@@ -52,6 +55,10 @@ public final class PrintingAgentAuthorization
/** Bind only on the explicit endpoint; all ordinary requests require the existing owner. */
public static String device(String id, String token, boolean bind) throws Exception
{
if(PrintingStationAuthorization.isStation())
{
return PrintingStationAuthorization.device(id, token, bind);
}
String principal = PrintingControlAuthorization.principal();
PrintingControlAuthorization.record(PrintingTables.DEVICE, id);
PrintingProcesses.currentService().authorizeAgent(id, token, principal, bind);
@@ -47,6 +47,10 @@ final class PrintingApiSchemas
add(schemas, "Session", fields("principalId", text(200), "tenantId", text(200), "deviceId", id()), "principalId", "tenantId");
add(schemas, "EnrollmentRequest", fields("enrollmentCode", text(128), "deviceName", text(1024), "agentVersion", text(100)), "enrollmentCode", "deviceName", "agentVersion");
add(schemas, "Enrollment", fields("protocolVersion", number(), "deviceId", id(), "deviceToken", text(128).withDescription("Private device proof, returned once; never log."), "pollIntervalSeconds", number()), "protocolVersion", "deviceId", "deviceToken", "pollIntervalSeconds");
add(schemas, "StationIdentity", fields("principalId", text(200), "username", text(200), "secret", text(512).withDescription("Private identity-provider credential of this station, returned once; never log."),
"expiresAt", time().withNullable(true)), "principalId", "username", "secret", "expiresAt");
add(schemas, "StationEnrollment", fields("protocolVersion", number(), "deviceId", id(), "deviceToken", text(128).withDescription("Private device proof, returned once; never log."), "pollIntervalSeconds", number(),
"station", ref("StationIdentity")), "protocolVersion", "deviceId", "deviceToken", "pollIntervalSeconds", "station");
add(schemas, "Profile", fields("name", text(512).withNullable(true), "description", text(4096).withNullable(true), "location", text(512).withNullable(true)));
add(schemas, "MediaSelection", fields("id", id(), "revision", text(64)), "id", "revision");
add(schemas, "MediaProfile", fields("id", id(), "revision", text(64), "name", text(512), "widthMicrons", number(), "heightMicrons", number(), "dpiX", number(), "dpiY", number(),
@@ -56,7 +56,11 @@ public final class PrintingControlAuthorization
{
throw new QAuthenticationException("Sign in to continue");
}
new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null);
// A print station is admitted only inside a request that proved its own device; everywhere else the administrator rule stands.
if(PrintingStationAuthorization.provedDevice() == null)
{
new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null);
}
PrintingProcesses.currentService();
return QContext.getQSession().getUser().getIdReference();
}
@@ -79,6 +83,7 @@ public final class PrintingControlAuthorization
{
throw new QPermissionDeniedException("Record is not accessible");
}
PrintingStationAuthorization.requireOwn(table, row);
return row;
}
@@ -120,6 +125,10 @@ public final class PrintingControlAuthorization
/** The native process permission remains the domain write authority; generic table writes stay disabled. */
public static void process(String name) throws QException
{
if(PrintingStationAuthorization.isStation())
{
throw new QPermissionDeniedException("A print station cannot run printing management actions");
}
principal();
PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(name), name);
}
@@ -82,6 +82,8 @@ public class PrintingProcesses implements BackendStep
PrintingJobProcesses.register(instance);
registerProfiles(instance);
add(instance, "printingEnrollDevice", "Enroll a print device", List.of(PrintingFacilities.field(instance), field("deviceName", "Device name")));
add(instance, "printingEnrollStation", "Enroll a print station", List.of(PrintingFacilities.field(instance), field("deviceName", "Device name"),
field("stationName", "Station name (lowercase letters, digits and hyphens, for example dock-3)")));
add(instance, "printingEnablePrinter", "Set printer approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true)));
add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"), profileField(false)));
add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"), profileField(true)));
@@ -189,7 +191,7 @@ public class PrintingProcesses implements BackendStep
.withStep(new QFrontendStepMetaData().withName("details").withLabel(label).withFormFields(fields).withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(PrintingProcesses.class)))
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result").withViewFields(List.of(field("result", "Result"))).withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM)));
if("printingEnrollDevice".equals(name))
if("printingEnrollDevice".equals(name) || "printingEnrollStation".equals(name))
{
process.withTableName(PrintingTables.DEVICE).withMinInputRecords(0);
process.withStep(0, new QBackendStepMetaData().withName("prepareEnrollment").withCode(new QCodeReference(PrepareEnrollment.class)));
@@ -241,17 +243,29 @@ public class PrintingProcesses implements BackendStep
** Find the current instance's runtime, never a shared global cached service.
***************************************************************************/
public static PrintingService currentService() throws QException
{
PrintingService service = configuredService();
Map<String, List<Serializable>> keys = QContext.getQSession() == null ? null : QContext.getQSession().getSecurityKeyValues();
List<Serializable> tenants = keys == null ? null : keys.get("printing_tenant");
if(tenants == null || !tenants.contains(service.getTenantId()))
{
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("The current session cannot manage this printing tenant");
}
return service;
}
/***************************************************************************
** The current instance's runtime with no session check. Only a request
** that carries its own authority (a station enrollment code) may use it.
***************************************************************************/
static PrintingService configuredService() throws QException
{
for(QBitMetaData metadata : QContext.getQInstance().getQBits().values())
{
if(metadata.getConfig() instanceof PrintingQBitConfig config && config.getService() != null)
{
Map<String, List<Serializable>> keys = QContext.getQSession() == null ? null : QContext.getQSession().getSecurityKeyValues();
List<Serializable> tenants = keys == null ? null : keys.get("printing_tenant");
if(tenants == null || !tenants.contains(config.getService().getTenantId()))
{
throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("The current session cannot manage this printing tenant");
}
return config.getService();
}
}
@@ -278,6 +292,13 @@ public class PrintingProcesses implements BackendStep
input.getValueString("printingEnrollmentCode"), input.getValueString("printingEnrollmentExpires"));
output.addValue("result", "Enrollment code: " + enrollment.getString("enrollmentCode") + " (expires " + enrollment.getString("expiresAt") + ")");
}
case "printingEnrollStation" ->
{
JSONObject enrollment = service.createEnrollment(PrintingFacilities.validate(input.getValueString("facilityId")), input.getValueString("deviceName"),
input.getValueString("printingEnrollmentCode"), input.getValueString("printingEnrollmentExpires"), service.stationName(input.getValueString("stationName")));
output.addValue("result", "Station enrollment code: " + enrollment.getString("enrollmentCode") + " (expires " + enrollment.getString("expiresAt")
+ "). Enter it once in the print client on the station; it creates the station's own identity and cannot be used to pair a person.");
}
case "printingEnablePrinter" ->
{
QRecord printer = resolvePrinter(input.getValueString("printerId"), input.getValueString("deviceId"));
@@ -96,7 +96,7 @@ public class PrintingQBitProducer implements QBitProducer
config.getService().configureStorage(instance, config.getStorageTableName());
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config));
instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant"));
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at", "removed_at"));
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at", "removed_at", "station_name"));
devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled"));
devices.withField(new QFieldMetaData("dispatch_paused", QFieldType.INTEGER).withLabel("Printing paused").withPossibleValueSourceName("printing_paused"));
devices.withField(new QFieldMetaData("connection_state", QFieldType.STRING).withLabel("Requested connection").withPossibleValueSourceName("printing_connectionState"));
@@ -139,6 +139,8 @@ public class PrintingQBitProducer implements QBitProducer
profileTable.getField("location").withMaxLength(512);
}
devices.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
// Set only on a device enrolled as a print station: it authenticates as its own identity, not as a person.
devices.getField("station_name").withLabel("Print station").withMaxLength(63);
printers.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
jobs.getField("title").withMaxLength(1024);
binaryValues(instance, "printing_enabled", "Disabled", "Enabled");
@@ -39,19 +39,24 @@ import org.jspecify.annotations.Nullable;
public class PrintingReadPermission extends PrintingAdminPermission
{
/***************************************************************************
** Require explicit printing administrator access and reject generic writes.
** Require explicit printing administrator access (or, inside a device
** request, the print station that proved its device) and reject generic writes.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table
&& (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName())))
// A print station that proved its device in this request reads through the device routes only, and they confine it to that device's rows.
if(PrintingStationAuthorization.provedDevice() == null)
{
new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata);
}
else
{
super.checkPermissionsThrowing(input, metadata);
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table
&& (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName())))
{
new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata);
}
else
{
super.checkPermissionsThrowing(input, metadata);
}
}
if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput)
&& !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput))
@@ -122,8 +122,9 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
// that lowercases header names would otherwise leave the device null on connect. Resolve it once here, after
// authorization, and carry it on the connection.
context.attribute(DEVICE_ATTRIBUTE, Objects.requireNonNull(context.header(PrintingAgentAuthorization.DEVICE_HEADER), "authorized upgrade without a device header"));
String cookieName = context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank() ? "sessionId" : "sessionUUID";
context.attribute("printingRealtimeCredential", new Credential(cookieName, context.cookie(cookieName), context.header(PrintingAgentAuthorization.TOKEN_HEADER), context.header("X-QQQ-Client")));
String bearer = bearer(context);
String cookieName = bearer != null ? BEARER_CONTEXT_KEY : context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank() ? "sessionId" : "sessionUUID";
context.attribute("printingRealtimeCredential", new Credential(cookieName, bearer != null ? bearer : context.cookie(cookieName), context.header(PrintingAgentAuthorization.TOKEN_HEADER), context.header("X-QQQ-Client")));
}
catch(Exception exception)
{
@@ -251,14 +252,28 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
}
/***************************************************************************
** The bearer token of an upgrade request, or null. A print station has no
** stored session: it presents the access token its identity provider gave
** it. Any other Authorization scheme is not a credential for this route.
***************************************************************************/
private static @Nullable String bearer(Context context)
{
String authorization = context.header("Authorization");
return authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7) && !authorization.substring(7).isBlank() ? authorization.substring(7).strip() : null;
}
/** Reject query credentials, non-session authentication and foreign origins. */
private static void validateUpgrade(Context context)
{
boolean cookie = (context.cookie("sessionUUID") != null && !context.cookie("sessionUUID").isBlank()) || (context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank());
// Exactly one credential: a person's session cookie, or a print station's bearer token. Never both, and never another scheme.
if(context.queryString() != null || !SUBPROTOCOL.equals(context.header("Sec-WebSocket-Protocol"))
|| ((context.cookie("sessionUUID") == null || context.cookie("sessionUUID").isBlank()) && (context.cookie("sessionId") == null || context.cookie("sessionId").isBlank()))
|| context.header("Authorization") != null || context.header("x-api-key") != null || context.header("X-QQQ-In-Process-Session") != null)
|| cookie == (bearer(context) != null) || (context.header("Authorization") != null && bearer(context) == null)
|| context.header("x-api-key") != null || context.header("X-QQQ-In-Process-Session") != null)
{
throw new PrintingException(403, "A standard QQQ session and the printing subprotocol are required");
}
@@ -282,10 +297,24 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
private void authorize(Context context) throws Exception
{
ExecutorSessionUtils.setupSession(context, instance);
requireStationForBearer(bearer(context) != null);
PrintingAgentAuthorization.device(context.header(PrintingAgentAuthorization.DEVICE_HEADER), context.header(PrintingAgentAuthorization.TOKEN_HEADER), false);
}
/***************************************************************************
** A bearer token holds this channel only for a print station. A person,
** whatever their role, holds it with the session they signed in to.
***************************************************************************/
private static void requireStationForBearer(boolean bearer) throws QPermissionDeniedException
{
if(bearer && !PrintingStationAuthorization.isStation())
{
throw new QPermissionDeniedException("Only a print station holds the realtime channel with a bearer token");
}
}
/***************************************************************************
** Revalidation requires the host instance already used during upgrade.
** Check at use, so route registration ordering remains unchanged.
@@ -302,6 +331,10 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
/** The authentication-context key QQQ modules read a bearer access token from. */
private static final String BEARER_CONTEXT_KEY = "accessToken";
/** Only transport credentials survive upgrade; user roles and session authority are never cached. */
private static final class Credential
{
@@ -434,6 +467,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
throw new QPermissionDeniedException("The current client channel is not allowed");
}
session.setClientChannel(channel == null ? null : channel.strip());
// A station's token is verified again here on every operation: once it expires the channel ends and the station reconnects with a new one.
requireStationForBearer(BEARER_CONTEXT_KEY.equals(credential.cookieName));
PrintingAgentAuthorization.device(device, credential.proof, false);
}
@@ -65,6 +65,7 @@ public class PrintingService
{
throw new PrintingException(503, "The host has not registered the realtime printing route provider");
};
private @Nullable PrintingStationIdentities stationIdentities;
/** Native claims require a synchronized current-user channel, not a polling fallback. */
@@ -76,6 +77,17 @@ public class PrintingService
}
/***************************************************************************
** The host's issuer of print station identities. Without one there is no
** station enrollment: a station code cannot be created or redeemed.
***************************************************************************/
public PrintingService withStationIdentities(PrintingStationIdentities identities)
{
stationIdentities = java.util.Objects.requireNonNull(identities);
return this;
}
/** Route-provider lifecycle owns the readiness guard. */
void withRealtimeClaimGuard(RealtimeClaimGuard guard)
{
@@ -383,6 +395,9 @@ public class PrintingService
PrintingDeviceControls.initialize(connection);
PrintingRealtimeConnections.initialize(connection);
update(connection, "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS bound_user_id VARCHAR(200)");
// Additive and nullable: a station enrollment code names its station, and the device it creates keeps that name.
update(connection, "ALTER TABLE printing_enrollment ADD COLUMN IF NOT EXISTS station_name VARCHAR(63)");
update(connection, "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS station_name VARCHAR(63)");
widenColumn(connection, "printing_device", "name", 1024);
widenColumn(connection, "printing_enrollment", "device_name", 1024);
widenColumn(connection, "printing_printer", "name", 1024);
@@ -432,6 +447,7 @@ public class PrintingService
{
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor);
realtimeBus.publish(tenantId, deviceId);
revokeStationIdentity(deviceId, request);
return result;
}
@@ -441,6 +457,7 @@ public class PrintingService
{
JSONObject result = new PrintingDeviceControls(dataSource, tenantId).action(deviceId, request, actor, authorizedQueue);
realtimeBus.publish(tenantId, deviceId);
revokeStationIdentity(deviceId, request);
return result;
}
@@ -562,6 +579,48 @@ public class PrintingService
** Replay reuses only an identical, unexpired, unconsumed hashed receipt.
***************************************************************************/
JSONObject createEnrollment(String facilityId, String deviceName, String code, String expires) throws SQLException
{
return createEnrollment(facilityId, deviceName, code, expires, null);
}
/***************************************************************************
** Issue a single-use enrollment code for a print station: the device that
** redeems it authenticates as its own identity, named here, instead of
** under a person's sign-in. Plaintext is returned only here.
***************************************************************************/
public JSONObject createStationEnrollment(String facilityId, String deviceName, String stationName) throws SQLException
{
return createEnrollment(facilityId, deviceName, secret(), Instant.now().plusSeconds(900).toString(), stationName(stationName));
}
/***************************************************************************
** A station name is a lowercase identifier (the host builds an account
** name from it), and a station code needs the host's identity issuer.
***************************************************************************/
String stationName(@Nullable String stationName)
{
if(stationIdentities == null)
{
throw new PrintingException(503, "Print station enrollment is not configured on this host");
}
if(stationName == null || !stationName.matches("[a-z][a-z0-9-]{0,62}"))
{
throw new PrintingException(400, "Invalid stationName; use lowercase letters, digits and hyphens, starting with a letter, at most 63 characters");
}
return stationName;
}
/***************************************************************************
** One prepared code, for a person's pairing (stationName null) or for a
** print station. The kind is part of the receipt: a replay cannot change it.
***************************************************************************/
JSONObject createEnrollment(String facilityId, String deviceName, String code, String expires, @Nullable String stationName) throws SQLException
{
required(facilityId, "facilityId");
required(deviceName, "deviceName");
@@ -584,7 +643,7 @@ public class PrintingService
{
try
{
update(connection, "INSERT INTO printing_enrollment (code_hash, tenant_id, facility_id, device_name, expires_at) VALUES (?, ?, ?, ?, ?)", hash(code), tenantId, facilityId, deviceName, expires);
update(connection, "INSERT INTO printing_enrollment (code_hash, tenant_id, facility_id, device_name, expires_at, station_name) VALUES (?, ?, ?, ?, ?, ?)", hash(code), tenantId, facilityId, deviceName, expires, stationName);
}
catch(SQLException exception)
{
@@ -603,7 +662,8 @@ public class PrintingService
{
JSONObject old = previous.getFirst();
if(old.getInt("consumed") != 0 || !tenantId.equals(old.getString("tenant_id")) || !facilityId.equals(old.getString("facility_id"))
|| !deviceName.equals(old.getString("device_name")) || !expires.equals(old.getString("expires_at")))
|| !deviceName.equals(old.getString("device_name")) || !expires.equals(old.getString("expires_at"))
|| !java.util.Objects.equals(stationName, old.optString("station_name", null)))
{
throw new PrintingException(409, "Enrollment request was already consumed or changed; restart enrollment");
}
@@ -628,7 +688,7 @@ public class PrintingService
{
try(Connection connection = dataSource.getConnection())
{
List<JSONObject> rows = query(connection, "SELECT facility_id FROM printing_enrollment WHERE tenant_id=? AND code_hash=? AND consumed=0 AND expires_at>?", tenantId, hash(required(code, "enrollmentCode")), Instant.now().toString());
List<JSONObject> rows = query(connection, "SELECT facility_id FROM printing_enrollment WHERE tenant_id=? AND code_hash=? AND consumed=0 AND expires_at>? AND station_name IS NULL", tenantId, hash(required(code, "enrollmentCode")), Instant.now().toString());
if(rows.isEmpty())
{
throw new PrintingException(401, "Enrollment code is invalid or expired");
@@ -652,7 +712,9 @@ public class PrintingService
try
{
List<JSONObject> codes = query(connection, "SELECT * FROM printing_enrollment WHERE code_hash=? AND tenant_id=? FOR UPDATE", codeHash, tenantId);
if(codes.isEmpty() || codes.getFirst().getInt("consumed") != 0 || !Instant.parse(codes.getFirst().getString("expires_at")).isAfter(Instant.now()))
// A station code is redeemed only by enrollStation: it never pairs a device to a person, or to nobody.
if(codes.isEmpty() || codes.getFirst().getInt("consumed") != 0 || !Instant.parse(codes.getFirst().getString("expires_at")).isAfter(Instant.now())
|| codes.getFirst().optString("station_name", null) != null)
{
throw new PrintingException(401, "Enrollment code is invalid or expired");
}
@@ -692,8 +754,149 @@ public class PrintingService
}
/***************************************************************************
** Redeem a print station's enrollment code: issue the station's identity
** through the host, create the one device bound to it, and return the
** device proof and the identity's credential. Both secrets appear only in
** this response. The code, not a signed-in person, authorizes the request;
** an administrator created it for exactly this purpose.
***************************************************************************/
public JSONObject enrollStation(JSONObject request) throws SQLException
{
PrintingStationIdentities identities = stationIdentities;
if(identities == null)
{
throw new PrintingException(503, "Print station enrollment is not configured on this host");
}
String codeHash = hash(required(request.optString("enrollmentCode"), "enrollmentCode"));
String computerName = required(request.optString("deviceName"), "deviceName");
String agentVersion = required(request.optString("agentVersion"), "agentVersion");
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
PrintingStationIdentities.Issued issued = null;
boolean created = false;
try
{
List<JSONObject> codes = query(connection, "SELECT * FROM printing_enrollment WHERE code_hash=? AND tenant_id=? FOR UPDATE", codeHash, tenantId);
String station = codes.isEmpty() ? null : codes.getFirst().optString("station_name", null);
if(station == null || codes.getFirst().getInt("consumed") != 0 || !Instant.parse(codes.getFirst().getString("expires_at")).isAfter(Instant.now()))
{
throw new PrintingException(401, "Enrollment code is invalid or expired");
}
// One station, one identity, one device. Checked before anything is issued, so a live station's identity is never touched.
if(!query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND station_name=? AND revoked_at IS NULL", tenantId, station).isEmpty())
{
throw new PrintingException(409, "A print station with this name is already enrolled; revoke its device before enrolling it again");
}
issued = issueStationIdentity(identities, station);
if(!query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND bound_user_id=? AND revoked_at IS NULL", tenantId, issued.principalId()).isEmpty())
{
throw new PrintingException(409, "The station identity is already bound to a device");
}
created = true;
String id = UUID.randomUUID().toString();
String token = secret();
update(connection, "INSERT INTO printing_device (id, tenant_id, facility_id, name, agent_version, token_hash, last_seen_at, enabled, computer_name, bound_user_id, station_name) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)",
id, tenantId, codes.getFirst().getString("facility_id"), computerName, agentVersion, hash(token), Instant.now().toString(), computerName, issued.principalId(), station);
update(connection, "UPDATE printing_enrollment SET consumed=1 WHERE code_hash=?", codeHash);
connection.commit();
LOG.info("Enrolled a print station", logPair("deviceId", id), logPair("stationName", station));
JSONObject identity = new JSONObject().put("principalId", issued.principalId()).put("username", issued.username()).put("secret", issued.secret())
.put("expiresAt", issued.expiresAt() == null ? JSONObject.NULL : issued.expiresAt());
return new JSONObject().put("protocolVersion", 1).put("deviceId", id).put("deviceToken", token).put("pollIntervalSeconds", 5).put("station", identity);
}
catch(Exception exception)
{
connection.rollback();
if(issued != null && created)
{
// The identity was created for a device that does not exist: end it, so no credential outlives a failed enrollment.
try
{
identities.revoke(issued.principalId());
}
catch(Exception revokeFailure)
{
LOG.warn("A station identity issued for a failed enrollment could not be revoked; revoke it at the identity provider", logPair("errorType", revokeFailure.getClass().getSimpleName()));
}
}
throw exception;
}
}
}
/** The host's failure never reaches the device as detail; the code stays unconsumed and can be tried again. */
private static PrintingStationIdentities.Issued issueStationIdentity(PrintingStationIdentities identities, String station)
{
try
{
return java.util.Objects.requireNonNull(identities.issue(station), "The station identity issuer returned nothing");
}
catch(Exception exception)
{
LOG.warn("A station identity could not be issued", logPair("stationName", station), logPair("errorType", exception.getClass().getSimpleName()));
throw new PrintingException(503, "The station identity could not be issued; try again");
}
}
/***************************************************************************
** Revoking a station device also ends its identity at the provider, so the
** credential is dead in both places. The device is already revoked here
** when this runs; if the provider cannot be reached the administrator is
** told, and repeating the action repeats only this step.
***************************************************************************/
private void revokeStationIdentity(String deviceId, JSONObject request) throws SQLException
{
if(!"REVOKE".equals(request.optString("action")))
{
return;
}
String principal;
try(Connection connection = dataSource.getConnection())
{
List<JSONObject> rows = query(connection, "SELECT bound_user_id FROM printing_device WHERE tenant_id=? AND id=? AND station_name IS NOT NULL AND revoked_at IS NOT NULL", tenantId, deviceId);
principal = rows.isEmpty() ? null : rows.getFirst().optString("bound_user_id", null);
}
if(principal == null)
{
return;
}
PrintingStationIdentities identities = stationIdentities;
try
{
if(identities == null)
{
throw new IllegalStateException("No station identity issuer is configured");
}
identities.revoke(principal);
}
catch(Exception exception)
{
LOG.warn("A revoked station device's identity could not be revoked at the identity provider", logPair("deviceId", deviceId), logPair("errorType", exception.getClass().getSimpleName()));
throw new PrintingException(502, "Device access is revoked here, but its station identity could not be revoked at the identity provider. Repeat the action.");
}
}
/** A print station proves the one device enrolled for its identity; it never binds another. */
public void authorizeStation(String deviceId, String token, String principalId) throws SQLException
{
authorize(deviceId, token, principalId, false, true);
}
/** Secondary device proof never replaces the caller's native QQQ authentication and row permission. */
public void authorizeAgent(String deviceId, String token, String principalId, boolean bind) throws SQLException
{
authorize(deviceId, token, principalId, bind, false);
}
/** One locked check of the device proof and of the principal the device is bound to. */
private void authorize(String deviceId, String token, String principalId, boolean bind, boolean station) throws SQLException
{
if(deviceId == null || token == null || token.length() > 128 || principalId == null || principalId.isBlank() || principalId.length() > 200)
{
@@ -704,8 +907,10 @@ public class PrintingService
connection.setAutoCommit(false);
try
{
List<JSONObject> rows = query(connection, "SELECT token_hash,bound_user_id FROM printing_device WHERE tenant_id=? AND id=? AND enabled=1 AND revoked_at IS NULL FOR UPDATE", tenantId, deviceId);
if(rows.isEmpty() || !MessageDigest.isEqual(rows.getFirst().getString("token_hash").getBytes(StandardCharsets.UTF_8), hash(token).getBytes(StandardCharsets.UTF_8)))
List<JSONObject> rows = query(connection, "SELECT token_hash,bound_user_id,station_name FROM printing_device WHERE tenant_id=? AND id=? AND enabled=1 AND revoked_at IS NULL FOR UPDATE", tenantId, deviceId);
// A station identity uses only a device enrolled as a station, and a person never takes over a station's device.
if(rows.isEmpty() || !MessageDigest.isEqual(rows.getFirst().getString("token_hash").getBytes(StandardCharsets.UTF_8), hash(token).getBytes(StandardCharsets.UTF_8))
|| station != (rows.getFirst().optString("station_name", null) != null))
{
throw new PrintingException(403, "User or device is not authorized");
}
@@ -0,0 +1,127 @@
/*
* 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 com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.jspecify.annotations.Nullable;
/***************************************************************************
** Authorization of a print station: a device that authenticates as its own
** identity instead of under a person's sign-in.
**
** A station session holds printing_station and not printing_admin. That
** permission alone opens nothing: no table, process or administrative
** route accepts it. It is honored only on the device routes, and only after
** the request has proved the one device bound to this identity. From then
** until the request ends (the proof is kept in the request's QContext, never
** on the session) the station may read the rows of that device and report
** for it: heartbeat, inventory, claim, content, job events, control poll and
** receipts. Every row it reads is checked to belong to that device.
***************************************************************************/
public final class PrintingStationAuthorization
{
public static final String PERMISSION = "printing_station";
private static final String PROVED_DEVICE = "printing.station.provedDevice";
/** Static authorization facade. */
private PrintingStationAuthorization()
{
}
/** A session that is a station and nothing more. An administrator keeps the administrator rules. */
public static boolean isStation()
{
QSession session = QContext.getQSession();
return session != null && session.hasPermission(PERMISSION) && !session.hasPermission("printing_admin");
}
/** The device this request proved as a station, or null. A proof counts only for the session that made it. */
// Identity ties the proof to the exact session object of this request; an equal-looking session never inherits it.
@SuppressWarnings("ReferenceEquality")
static @Nullable String provedDevice()
{
Proof proof = isStation() ? QContext.getObject(PROVED_DEVICE, Proof.class).orElse(null) : null;
return proof != null && proof.session == QContext.getQSession() ? proof.device : null;
}
/***************************************************************************
** Prove the station's device: the identity, the tenant, the device token
** and the binding of that device to this identity are all required. A
** station never binds an existing device; it is bound when it enrolls.
***************************************************************************/
static String device(@Nullable String id, @Nullable String token, boolean bind) throws Exception
{
QSession session = QContext.getQSession();
if(session == null || session.getUser() == null || session.getUser().getIdReference() == null || session.getUser().getIdReference().isBlank())
{
throw new QAuthenticationException("Sign in to continue");
}
if(bind || id == null || token == null)
{
throw new QPermissionDeniedException("A print station uses only the device it enrolled");
}
QContext.removeObject(PROVED_DEVICE);
PrintingProcesses.currentService().authorizeStation(id, token, session.getUser().getIdReference());
QContext.setObject(PROVED_DEVICE, new Proof(session, id));
PrintingControlAuthorization.record(PrintingTables.DEVICE, id);
return id;
}
/** Inside a station request, a row that is not the proved device's own is refused. */
static void requireOwn(String table, QRecord row) throws QException
{
String device = provedDevice();
if(device != null && !device.equals(row.getValueString(PrintingTables.DEVICE.equals(table) ? "id" : "device_id")))
{
throw new QPermissionDeniedException("Record is not accessible");
}
}
/** The proved device and the session that proved it; the session reference never leaves this thread's context. */
private static final class Proof implements java.io.Serializable
{
private static final long serialVersionUID = 1L;
private final transient @Nullable QSession session;
private final String device;
/** Record one successful proof. */
private Proof(QSession session, String device)
{
this.session = session;
this.device = device;
}
}
}
@@ -0,0 +1,92 @@
/*
* 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 org.jspecify.annotations.Nullable;
/***************************************************************************
** The host's issuer of print station identities.
**
** A print station is an unattended computer beside a printer. It does not
** run under a person's sign-in: it authenticates to the application as its
** own identity, which the host's identity provider issues and can revoke.
** This QBit does not know the provider; the host supplies this interface
** (PrintingService.withStationIdentities) and the QBit calls it when an
** administrator's station enrollment code is redeemed and when a station
** device is revoked.
**
** A host that supplies no issuer has no station enrollment: the route
** answers 503 and nothing is created.
***************************************************************************/
public interface PrintingStationIdentities
{
/***************************************************************************
** Create the identity of one station and return the credential its device
** stores. Called once per redeemed enrollment code, inside the enrollment
** transaction; if the enrollment then fails, revoke is called for the
** returned principal. The secret is returned to the device once and is
** never stored or logged by this QBit.
***************************************************************************/
Issued issue(String stationName) throws Exception;
/***************************************************************************
** End a station identity at the provider. Must be safe to repeat: it is
** called again when an administrator repeats a revoke that failed here.
***************************************************************************/
void revoke(String principalId) throws Exception;
/***************************************************************************
** One issued station identity.
**
** principalId is the user reference the host gives a session authenticated
** with this credential (QSession.getUser().getIdReference()); the device is
** bound to it. username and secret are what the device presents to the
** identity provider; expiresAt is the secret's RFC3339 expiry, or null.
***************************************************************************/
record Issued(String principalId, String username, String secret, @Nullable String expiresAt)
{
/** Refuse an identity a device could not be bound to or could not present. */
public Issued
{
if(principalId == null || principalId.isBlank() || principalId.length() > 200 || username == null || username.isBlank() || username.length() > 200
|| secret == null || secret.isBlank() || secret.length() > 512 || (expiresAt != null && expiresAt.length() > 40))
{
throw new IllegalArgumentException("Invalid station identity");
}
}
/** The secret never appears in logs or exception text. */
@Override
public String toString()
{
return "Printing station identity [redacted]";
}
}
}
@@ -49,14 +49,15 @@ public final class PrintingUiApi
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade; 2.4.0 adds the printer-approval-v1 capability and the realtime PRINTERS frame carrying each exposed printer's approval. */
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes; 2.2.0 adds printingRenderToPdf, the header/footer values and content sections; 2.3.0 adds authorized operator submission, reported queue-default media and the retained-source facade; 2.4.0 adds the printer-approval-v1 capability and the realtime PRINTERS frame carrying each exposed printer's approval; 2.5.0 adds print stations: POST /stations/enroll, and a station identity (printing_station) on the device routes and the realtime channel. */
public static MiddlewareSpecExtension extension()
{
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.4.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1; printer-approval-v1 and the realtime PRINTERS approval frame.")
return new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.5.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with the app header and footer; least-privilege operator catalog and printer-media-v1; printer-approval-v1 and the realtime PRINTERS approval frame; print stations that authenticate as their own identity.")
.withSpec(Session.class)
.withSpec(Bind.class)
.withSpec(Enroll.class)
.withSpec(StationEnroll.class)
.withSpec(Heartbeat.class)
.withSpec(Inventory.class)
.withSpec(Claim.class)
@@ -161,6 +162,26 @@ public final class PrintingUiApi
}
/** Station enrollment endpoint: the one-use station code is the request's authority, so no session is set up. */
public static class StationEnroll extends PrintingUiApiSpec
{
/** Public no-argument constructor required by the core extension registry. */
public StationEnroll()
{
super("StationEnroll", HttpMethod.POST, "/stations/enroll", "EnrollmentRequest", "StationEnrollment");
}
/** No QQQ session exists yet: this request is what gives the station its identity. */
@Override
public boolean isSecured()
{
return false;
}
}
/** Heartbeat native endpoint. */
public static class Heartbeat extends PrintingUiApiSpec
{
@@ -74,6 +74,8 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
return new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path)
.withOperationId("printing" + operation).withShortSummary("Printing " + operation)
.withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority."
: "StationEnroll".equals(operation) ? "Redeems a one-use print station enrollment code created by a printing administrator. No session: the code is the authority. Returns the device proof and the station identity's credential once."
: path.startsWith("/device/") || "Session".equals(operation) ? "Requires the current QQQ session of a printing administrator, or of the print station identity this device enrolled as (printing_station), and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."
: "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication.");
}
@@ -139,10 +141,28 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
public JsonInput buildInput(Context context) throws Exception
{
context.header("Cache-Control", "no-store");
if(operation.startsWith("Operator"))
String id = context.header(PrintingAgentAuthorization.DEVICE_HEADER);
String token = context.header(PrintingAgentAuthorization.TOKEN_HEADER);
boolean deviceRoute = path.startsWith("/device/") || ("Session".equals(operation) && (id != null || token != null));
String stationDevice = null;
if("StationEnroll".equals(operation))
{
// No session and no principal: the handler redeems the code or refuses.
if(context.bodyAsBytes().length > 32768)
{
throw new PrintingException(413, "Printing request exceeds its size limit");
}
return super.buildInput(context);
}
else if(operation.startsWith("Operator"))
{
PrintingOperatorAuthorization.principal();
}
else if(deviceRoute && PrintingStationAuthorization.isStation())
{
// A print station has no administrator authority: its device proof is what admits it, so it is checked first.
stationDevice = PrintingAgentAuthorization.device(id, token, "Bind".equals(operation));
}
else
{
PrintingControlAuthorization.principal();
@@ -163,11 +183,9 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
{
throw new QBadRequestException("This operation requires an empty JSON object");
}
String id = context.header(PrintingAgentAuthorization.DEVICE_HEADER);
String token = context.header(PrintingAgentAuthorization.TOKEN_HEADER);
if(path.startsWith("/device/") || ("Session".equals(operation) && (id != null || token != null)))
if(deviceRoute)
{
input.getPathParams().put("_verifiedDevice", PrintingAgentAuthorization.device(id, token, "Bind".equals(operation)));
input.getPathParams().put("_verifiedDevice", stationDevice != null ? stationDevice : PrintingAgentAuthorization.device(id, token, "Bind".equals(operation)));
}
return input;
}
@@ -177,6 +195,10 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec
@Override
protected Object handle(JsonInput input) throws Exception
{
if("StationEnroll".equals(operation))
{
return PrintingProcesses.configuredService().enrollStation(new JSONObject(input.getBody())).toMap();
}
PrintingService service = PrintingProcesses.currentService();
if(operation.startsWith("Operator"))
{
+140 -15
View File
@@ -689,6 +689,53 @@ components:
- "principalId"
- "tenantId"
type: "object"
PrintingStationEnrollmentV1:
properties:
protocolVersion:
type: "integer"
deviceId:
maxLength: 128
type: "string"
deviceToken:
description: "Private device proof, returned once; never log."
maxLength: 128
type: "string"
pollIntervalSeconds:
type: "integer"
station:
$ref: "#/components/schemas/PrintingStationIdentityV1"
required:
- "protocolVersion"
- "deviceId"
- "deviceToken"
- "pollIntervalSeconds"
- "station"
type: "object"
PrintingStationIdentityV1:
properties:
principalId:
maxLength: 200
type: "string"
username:
maxLength: 200
type: "string"
secret:
description: "Private identity-provider credential of this station, returned\
\ once; never log."
maxLength: 512
type: "string"
expiresAt:
description: "RFC3339 UTC ending in uppercase Z."
format: "date-time"
maxLength: 40
nullable: true
type: "string"
required:
- "principalId"
- "username"
- "secret"
- "expiresAt"
type: "object"
PrintingTestRequestV1:
properties:
requestId:
@@ -754,14 +801,16 @@ info:
description: "Native current-user printing with secondary device proof, device-control-v1\
\ and realtime-v1; record, table-list and text printing processes; render to PDF\
\ with the app header and footer; least-privilege operator catalog and printer-media-v1;\
\ printer-approval-v1 and the realtime PRINTERS approval frame."
\ printer-approval-v1 and the realtime PRINTERS approval frame; print stations\
\ that authenticate as their own identity."
title: "QQQ Middleware API extension: printing"
version: "2.4.0"
version: "2.5.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/printing/session:
get:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingSession"
@@ -842,7 +891,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/bind:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingBind"
@@ -1000,9 +1050,74 @@ paths:
summary: "Printing Enroll"
tags:
- "printing"
/qqq/v1/qbit/printing/stations/enroll:
post:
description: "Redeems a one-use print station enrollment code created by a printing\
\ administrator. No session: the code is the authority. Returns the device\
\ proof and the station identity's credential once."
operationId: "printingStationEnroll"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingEnrollmentRequestV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Native QQQ sign-in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission, device proof or user binding denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Record not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/PrintingStationEnrollmentV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "State or request conflict"
410:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Request or content expired"
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Bounded capacity reached"
summary: "Printing StationEnroll"
tags:
- "printing"
/qqq/v1/qbit/printing/device/heartbeat:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingHeartbeat"
@@ -1089,7 +1204,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/printers:
put:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingInventory"
@@ -1176,7 +1292,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/claim:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingClaim"
@@ -1263,7 +1380,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/queued:
get:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingQueued"
@@ -1344,7 +1462,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/{jobId}/content:
get:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingContent"
@@ -1432,7 +1551,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/{jobId}/events:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingEvent"
@@ -1525,7 +1645,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/test-print:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingTest"
@@ -1612,7 +1733,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/{jobId}/retry:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingRetry"
@@ -1705,7 +1827,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/jobs/{jobId}/cancel:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingCancel"
@@ -1798,7 +1921,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/control/poll:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingPoll"
@@ -1885,7 +2009,8 @@ paths:
- "printing"
/qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt:
post:
description: "Requires the current QQQ user session, printing_admin permission\
description: "Requires the current QQQ session of a printing administrator,\
\ or of the print station identity this device enrolled as (printing_station),\
\ and native record access. Device operations additionally require secondary\
\ device headers bound to this principal. No primary device-bearer authentication."
operationId: "printingReceipt"
@@ -74,6 +74,23 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable
}
/** A print station has no cookie: it holds the channel with the bearer token of its own identity. */
static PrintingSocketFixture connectBearer(HttpClient client, String base, String bearer, @Nullable String cookie, String device, String proof) throws Exception
{
PrintingSocketFixture fixture = new PrintingSocketFixture();
WebSocket.Builder builder = client.newWebSocketBuilder().subprotocols(PrintingRealtimeRoutes.SUBPROTOCOL).header("Origin", base)
.header("X-QQQ-Client", "api/qqq-print-agent").header("Authorization", "Bearer " + bearer)
.header(PrintingAgentAuthorization.DEVICE_HEADER, device).header(PrintingAgentAuthorization.TOKEN_HEADER, proof);
if(cookie != null)
{
builder.header("Cookie", cookie);
}
fixture.socket = builder.buildAsync(URI.create(base.replace("http:", "ws:") + PrintingRealtimeRoutes.PATH), fixture).get(10, TimeUnit.SECONDS);
fixture.initialize();
return fixture;
}
/** Reconcile after the connection's subscription is already registered. */
void initialize() throws Exception
{
@@ -0,0 +1,584 @@
/*
* 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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher;
import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig;
import io.javalin.Javalin;
import org.json.JSONArray;
import org.json.JSONObject;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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;
/***************************************************************************
** A print station authenticates as its own identity, is confined to the one
** device it enrolled, and holds no administrator authority. Real QQQ, H2,
** HTTP and WebSocket; the identity provider is a recording stand-in.
***************************************************************************/
class PrintingStationTest
{
private static final String PASSWORD = "synthetic-station-test-password-1234";
@TempDir
Path directory;
/** Each test owns its native context. */
@AfterEach
void clearContext()
{
QContext.clear();
}
/** A station code creates exactly one identity and one device; nothing else redeems it, and a failure leaves no credential behind. */
@Test
void stationCodeIssuesOneIdentityBoundToOneDevice() throws Exception
{
var f = PrintingProcessesTest.fixture();
Identities identities = new Identities();
assertEquals(503, status(() -> f.service().createStationEnrollment("warehouse", "Dock PC", "dock-1")), "No issuer: no station code");
assertEquals(503, status(() -> f.service().enrollStation(request(f.service().createEnrollment("warehouse", "Person")))), "No issuer: no redemption");
f.service().withStationIdentities(identities);
for(String invalid : new String[] { "Dock 1", "1dock", "", "dock_1", "d".repeat(64) })
{
assertEquals(400, status(() -> f.service().createStationEnrollment("warehouse", "Dock PC", invalid)));
}
JSONObject code = request(f.service().createStationEnrollment("warehouse", "Dock PC", "dock-1"));
// A station code never pairs a device to a person, to the legacy bearer protocol, or to nobody.
assertEquals(401, status(() -> f.service().enrollForUser(code, "operator")));
assertEquals(401, status(() -> f.service().enroll(code)));
assertEquals(401, status(() -> f.service().enrollmentFacility(code.getString("enrollmentCode"))));
// A person's pairing code never creates a station.
JSONObject personCode = request(f.service().createEnrollment("warehouse", "Person"));
assertEquals(401, status(() -> f.service().enrollStation(personCode)));
assertEquals(401, status(() -> f.service().enrollStation(request(new JSONObject().put("enrollmentCode", "unknown-code")))));
assertEquals(List.of(), identities.issued);
// The provider being unavailable consumes nothing: the same code works when it is back.
identities.failIssue = true;
assertEquals(503, status(() -> f.service().enrollStation(code)));
identities.failIssue = false;
JSONObject enrolled = f.service().enrollStation(code);
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
JSONObject identity = enrolled.getJSONObject("station");
assertEquals("station:dock-1", identity.getString("principalId"));
assertEquals("svc-print-dock-1", identity.getString("username"));
assertEquals(identities.secrets.getLast(), identity.getString("secret"));
assertEquals("2099-01-01T00:00:00Z", identity.getString("expiresAt"));
assertEquals(List.of("dock-1"), identities.issued);
assertEquals(401, status(() -> f.service().enrollStation(code)), "A station code is used once");
// The device is bound to the station identity: nobody else proves it, and the station proves nothing else.
f.service().authorizeStation(device, proof, "station:dock-1");
assertEquals(403, status(() -> f.service().authorizeStation(device, proof, "station:dock-2")));
assertEquals(403, status(() -> f.service().authorizeStation(device, "wrong-proof", "station:dock-1")));
assertEquals(403, status(() -> f.service().authorizeAgent(device, proof, "station:dock-1", false)), "A station device is not a person's device");
assertEquals(403, status(() -> f.service().authorizeAgent(device, proof, "operator", true)), "A person cannot bind a station device");
JSONObject paired = f.service().enrollForUser(personCode, "station:dock-1");
assertEquals(403, status(() -> f.service().authorizeStation(paired.getString("deviceId"), paired.getString("deviceToken"), "station:dock-1")), "A station identity cannot use a person's device");
// One station, one device: a second enrollment of a live station is refused before anything is issued or revoked.
JSONObject again = request(f.service().createStationEnrollment("warehouse", "Dock PC replacement", "dock-1"));
assertEquals(409, status(() -> f.service().enrollStation(again)));
assertEquals(List.of("dock-1"), identities.issued);
assertEquals(List.of(), identities.revoked);
f.service().authorizeStation(device, proof, "station:dock-1");
// An identity the issuer hands out twice is refused, and the live device's identity is left alone.
identities.fixedPrincipal = "station:dock-1";
assertEquals(409, status(() -> f.service().enrollStation(request(f.service().createStationEnrollment("warehouse", "Dock 2", "dock-2")))));
assertEquals(List.of(), identities.revoked);
identities.fixedPrincipal = null;
// A failure after the identity was created ends that identity: no credential outlives a failed enrollment.
JSONObject broken = request(f.service().createStationEnrollment("warehouse", "Dock 3", "dock-3")).put("agentVersion", "v".repeat(101));
assertEquals(400, status(() -> f.service().enrollStation(broken)), "Invalid input is refused before the provider is asked");
assertEquals(List.of("dock-1", "dock-2"), identities.issued);
try(var connection = new DriverDataSource(jdbc(f), "sa", "").getConnection())
{
// Make the database refuse this one device, after its identity was issued.
PrintingService.update(connection, "ALTER TABLE printing_device ADD CONSTRAINT synthetic_refusal CHECK (station_name <> 'dock-3')");
}
assertThrows(Exception.class, () -> f.service().enrollStation(broken.put("agentVersion", "test")));
assertEquals(List.of("station:dock-3"), identities.revoked);
}
/** The station permission alone opens nothing; with its device proved, the station reads and reports only for that device. */
@Test
void stationIsConfinedToItsOwnDeviceAndHoldsNoAdministratorAuthority() throws Exception
{
var f = PrintingProcessesTest.fixture();
QSession administrator = QContext.getQSession();
Identities identities = new Identities();
f.service().withStationIdentities(identities);
JSONObject enrolled = f.service().enrollStation(request(f.service().createStationEnrollment("warehouse", "Dock PC", "dock-1")));
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
f.service().inventory(device, new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Dock laser").put("formats", new JSONArray(List.of("PDF")))));
f.service().enablePrinter(device, "laser", true);
String job = submit(f.service(), device, "station job");
String otherJob = submit(f.service(), f.device(), "another device's job");
QSession station = station("station:dock-1", "tenant-a");
QContext.setQSession(station);
assertTrue(PrintingStationAuthorization.isStation());
assertNull(PrintingStationAuthorization.provedDevice());
// Without a proved device the permission is worth nothing: no principal, no row, no table, no process.
assertThrows(QException.class, PrintingControlAuthorization::principal);
assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.DEVICE, device));
assertThrows(QException.class, () -> PrintingControlAuthorization.table(PrintingTables.JOB));
assertThrows(QException.class, () -> new PrintingReadPermission().checkPermissionsThrowing(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(), f.instance().getTable(PrintingTables.JOB)));
assertThrows(QException.class, () -> new PrintingProcesses().run(new RunBackendStepInput().withProcessName("printingEnrollStation"), new RunBackendStepOutput()));
// The proof is the device token, of the station's own device, and it never binds.
assertEquals(403, status(() -> PrintingAgentAuthorization.device(device, "wrong-proof", false)));
assertThrows(QException.class, () -> PrintingAgentAuthorization.device(device, proof, true));
assertThrows(QException.class, () -> PrintingStationAuthorization.device(device, null, false));
assertThrows(QException.class, () -> PrintingStationAuthorization.device(null, proof, false));
assertEquals(403, status(() -> PrintingAgentAuthorization.device(f.device(), proof, false)));
assertNull(PrintingStationAuthorization.provedDevice());
assertEquals(device, PrintingAgentAuthorization.device(device, proof, false));
assertEquals(device, PrintingStationAuthorization.provedDevice());
assertEquals("station:dock-1", PrintingControlAuthorization.principal());
// Its own rows, and only those.
PrintingControlAuthorization.job(device, job);
PrintingControlAuthorization.table(PrintingTables.JOB);
assertEquals(1, PrintingAgentAuthorization.inventory(device).size());
assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.JOB, otherJob));
assertThrows(QException.class, () -> PrintingControlAuthorization.job(f.device(), otherJob));
assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.DEVICE, f.device()));
assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.PRINTER, f.printerId()));
assertThrows(QException.class, () -> PrintingControlAuthorization.record(PrintingTables.DEVICE, f.foreignDevice()));
// No management action, even on its own device, and no generic write.
assertThrows(QException.class, () -> PrintingControlAuthorization.process("printingEnrollDevice"));
assertThrows(QException.class, () -> PrintingControlAuthorization.process(PrintingJobProcesses.CANCEL));
assertThrows(QException.class, () -> PrintingControlAuthorization.deviceAction(device, action("REVOKE")));
assertThrows(QException.class, () -> new PrintingReadPermission().checkPermissionsThrowing(new InsertInput(), f.instance().getTable(PrintingTables.JOB)));
// It claims and acknowledges its own job.
assertEquals(job, PrintingAgentAuthorization.claim(device).getJSONObject("job").getString("jobId"));
f.service().event(device, job, new JSONObject().put("eventId", "station-received").put("status", "RECEIVED"));
assertEquals("RECEIVED", f.service().getJob(job).getString("status"));
assertEquals("QUEUED", f.service().getJob(otherJob).getString("status"));
// The proof belongs to the session that made it: an identical session in the same thread starts with nothing.
QContext.setQSession(station("station:dock-1", "tenant-a"));
assertNull(PrintingStationAuthorization.provedDevice());
assertThrows(QException.class, PrintingControlAuthorization::principal);
// Another tenant's key, another identity, or no user at all prove nothing.
QContext.setQSession(station("station:dock-1", "tenant-b"));
assertThrows(QException.class, () -> PrintingAgentAuthorization.device(device, proof, false));
QContext.setQSession(station("station:dock-9", "tenant-a"));
assertEquals(403, status(() -> PrintingAgentAuthorization.device(device, proof, false)));
QContext.setQSession(new QSession().withPermissions(PrintingStationAuthorization.PERMISSION).withSecurityKeyValue("printing_tenant", "tenant-a"));
assertThrows(QAuthenticationException.class, () -> PrintingAgentAuthorization.device(device, proof, false));
// An administrator who also holds the station permission is an administrator, under the administrator's rules.
QContext.setQSession(new QSession().withUser(new QUser().withIdReference("operator")).withPermissions("printing_admin", PrintingStationAuthorization.PERMISSION).withSecurityKeyValue("printing_tenant", "tenant-a"));
assertFalse(PrintingStationAuthorization.isStation());
assertEquals(403, status(() -> PrintingAgentAuthorization.device(device, proof, false)), "A person never takes over a station's device");
// Revoking the device ends the identity at the provider too, and the station is refused at once.
QContext.setQSession(administrator);
PrintingControlAuthorization.deviceAction(device, action("REVOKE"));
assertEquals(List.of("station:dock-1"), identities.revoked);
QContext.setQSession(station);
assertEquals(403, status(() -> PrintingAgentAuthorization.device(device, proof, false)));
}
/** A provider that cannot be reached is reported, the device stays revoked, and repeating the action finishes the job. */
@Test
void revokeIsRepeatedUntilTheProviderConfirmsAndTheStationCanEnrollAgain() throws Exception
{
var f = PrintingProcessesTest.fixture();
Identities identities = new Identities();
f.service().withStationIdentities(identities);
JSONObject enrolled = f.service().enrollStation(request(f.service().createStationEnrollment("warehouse", "Dock PC", "dock-1")));
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
// Revoking a person's device does not touch any identity.
f.service().deviceAction(f.device(), action("REVOKE"), "administrator");
assertEquals(List.of(), identities.revoked);
identities.failRevoke = true;
JSONObject revoke = action("REVOKE");
assertEquals(502, status(() -> f.service().deviceAction(device, revoke, "administrator")));
assertEquals(403, status(() -> f.service().authorizeStation(device, proof, "station:dock-1")), "The device is revoked here whatever the provider says");
assertEquals(List.of(), identities.revoked);
identities.failRevoke = false;
f.service().deviceAction(device, revoke, "administrator");
assertEquals(List.of("station:dock-1"), identities.revoked);
// The name is free again: a new code gives the station a new device and a new credential.
JSONObject replaced = f.service().enrollStation(request(f.service().createStationEnrollment("warehouse", "Dock PC", "dock-1")));
assertFalse(device.equals(replaced.getString("deviceId")));
assertFalse(enrolled.getJSONObject("station").getString("secret").equals(replaced.getJSONObject("station").getString("secret")));
f.service().authorizeStation(replaced.getString("deviceId"), replaced.getString("deviceToken"), "station:dock-1");
assertEquals(403, status(() -> f.service().authorizeStation(device, proof, "station:dock-1")));
}
/** The administrator's process creates a station code, replays it unchanged, and refuses to turn it into another kind. */
@Test
void administratorProcessCreatesStationCodes() throws Exception
{
var f = PrintingProcessesTest.fixture();
RunBackendStepInput input = new RunBackendStepInput().withProcessName("printingEnrollStation")
.addValue("facilityId", "default").addValue("deviceName", "Dock PC").addValue("stationName", "dock-1");
RunBackendStepOutput prepared = new RunBackendStepOutput();
new PrintingProcesses.PrepareEnrollment().run(input, prepared);
input.addValue("printingEnrollmentCode", prepared.getValueString("printingEnrollmentCode")).addValue("printingEnrollmentExpires", prepared.getValueString("printingEnrollmentExpires"));
assertThrows(QException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput()), "No issuer: no station code");
f.service().withStationIdentities(new Identities());
RunBackendStepOutput created = new RunBackendStepOutput();
new PrintingProcesses().run(input, created);
assertTrue(created.getValueString("result").startsWith("Station enrollment code: " + prepared.getValueString("printingEnrollmentCode")));
RunBackendStepOutput replayed = new RunBackendStepOutput();
new PrintingProcesses().run(input, replayed);
assertEquals(created.getValueString("result"), replayed.getValueString("result"));
// The same prepared code cannot become a person's pairing code or another station's.
String code = prepared.getValueString("printingEnrollmentCode");
String expires = prepared.getValueString("printingEnrollmentExpires");
assertEquals(409, status(() -> f.service().createEnrollment("default", "Dock PC", code, expires)));
assertEquals(409, status(() -> f.service().createEnrollment("default", "Dock PC", code, expires, "dock-2")));
input.addValue("stationName", "Not A Name");
assertThrows(QException.class, () -> new PrintingProcesses().run(input, new RunBackendStepOutput()));
assertEquals("svc-print-dock-1", f.service().enrollStation(request(new JSONObject().put("enrollmentCode", code))).getJSONObject("station").getString("username"));
assertTrue(f.instance().getProcess("printingEnrollStation").getPermissionRules().getCustomPermissionChecker().getName().endsWith("PrintingAdminPermission"));
}
/***************************************************************************
** End to end over real HTTP and WebSocket. The station enrolls with the code
** alone, then works with a bearer credential and its device proof: no
** cookie, no person's session. Its permission opens no other route, and a
** person's bearer token never holds the realtime channel.
***************************************************************************/
@Test
void stationEnrollsAndPrintsOverHttpAndRealtimeWithItsOwnCredential() throws Exception
{
String jdbc = "jdbc:h2:file:" + directory.resolve("station").toString().replace('\\', '/');
PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", PASSWORD));
AtomicReference<Javalin> javalin = new AtomicReference<>();
QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
{
host.configure(server, false);
server.withJavalinConfigurationCustomizer(javalin::set);
}));
PrintingService service = java.util.Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
.map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService());
Identities identities = new Identities();
// The local host authenticates one account; the stand-in provider issues that account as the station's identity.
identities.fixedPrincipal = "operator";
String base = "http://127.0.0.1:" + java.util.Objects.requireNonNull(javalin.get(), "Test HTTP server was not started").port();
String prefix = "/qqq/v1" + PrintingUiApi.PREFIX;
var auth = launcher.getQInstance().getAuthentication();
var administratorModule = auth.getModuleCodeReference();
try(HttpClient client = HttpClient.newHttpClient())
{
JSONObject unconfigured = request(new JSONObject().put("enrollmentCode", "no-issuer-on-this-host"));
assertEquals(503, send(client, base, prefix + "/stations/enroll", "POST", unconfigured, null, null, null, null).statusCode());
service.withStationIdentities(identities);
// Enrollment: no session of any kind; the one-use code is the authority.
JSONObject code = request(service.createStationEnrollment("default", "Dock PC", "dock-1"));
assertEquals(401, send(client, base, prefix + "/stations/enroll", "POST", request(new JSONObject().put("enrollmentCode", "not-a-code")), null, null, null, null).statusCode());
JSONObject enrolled = json(send(client, base, prefix + "/stations/enroll", "POST", code, null, null, null, null), 200);
String device = enrolled.getString("deviceId");
String proof = enrolled.getString("deviceToken");
assertEquals("svc-print-dock-1", enrolled.getJSONObject("station").getString("username"));
assertEquals(identities.secrets.getLast(), enrolled.getJSONObject("station").getString("secret"));
assertEquals(401, send(client, base, prefix + "/stations/enroll", "POST", code, null, null, null, null).statusCode());
service.inventory(device, new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL"))));
service.enablePrinter(device, "queue", true);
byte[] content = "^XA^FO10,10^FDstation^FS^XZ".getBytes(StandardCharsets.UTF_8);
String job = service.submit(new JSONObject().put("deviceId", device).put("printerId", "queue").put("title", "Station label")
.put("format", "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(content)).put("idempotencyKey", "station-http")).getString("jobId");
// The station's credential: with the local host's table sessions the bearer value is the session id; under OAuth2 it is the provider's access token.
String cookie = login(client, base);
String bearer = cookie.substring("sessionUUID=".length());
// While the bearer belongs to an administrator it is a person's credential: it never holds the realtime channel.
assertThrows(Exception.class, () -> PrintingSocketFixture.connectBearer(client, base, bearer, null, device, proof));
auth.withModuleCodeReference(new QCodeReference(StationAuthentication.class));
try
{
// The station permission opens no administrative, pairing, operator or generic route.
assertEquals(403, send(client, base, prefix + "/admin/devices/" + device + "/actions", "POST", action("REVOKE"), bearer, null, null, null).statusCode());
assertEquals(403, send(client, base, prefix + "/admin/devices/" + device + "/commands", "GET", null, bearer, null, null, null).statusCode());
assertEquals(403, send(client, base, prefix + "/devices/enroll", "POST", request(service.createEnrollment("default", "Person")), bearer, null, null, null).statusCode());
assertEquals(403, send(client, base, prefix + "/operator/printers", "GET", null, bearer, null, null, null).statusCode());
assertTrue(Set.of(403, 404).contains(send(client, base, "/qqq/v1/table/" + PrintingTables.JOB + "/query", "POST", new JSONObject(), bearer, null, null, null).statusCode()));
assertTrue(Set.of(403, 404).contains(send(client, base, "/qqq/v1/table/" + PrintingTables.DEVICE + "/query", "POST", new JSONObject(), bearer, device, proof, null).statusCode()),
"Device headers on a generic route prove nothing");
assertEquals(403, send(client, base, prefix + "/session", "GET", null, bearer, null, null, null).statusCode());
// On the device routes the proof is required, must be this station's, and never binds.
assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), bearer, null, null, null).statusCode());
assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), bearer, device, "wrong-proof", null).statusCode());
assertEquals(403, send(client, base, prefix + "/device/bind", "POST", new JSONObject(), bearer, device, proof, null).statusCode());
JSONObject session = json(send(client, base, prefix + "/session", "GET", null, bearer, device, proof, null), 200);
assertEquals("operator", session.getString("principalId"));
assertEquals(device, session.getString("deviceId"));
json(send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), bearer, device, proof, null), 200);
assertEquals(1, json(send(client, base, prefix + "/device/jobs/queued", "GET", null, bearer, device, proof, null), 200).getJSONArray("jobs").length());
json(send(client, base, prefix + "/device/printers", "PUT", new JSONObject().put("printers", new JSONArray().put(new JSONObject().put("printerId", "queue")
.put("name", "Synthetic queue").put("status", "READY").put("formats", new JSONArray().put("ZPL")))), bearer, device, proof, null), 200);
// No management action through the device routes either.
assertEquals(403, send(client, base, prefix + "/device/test-print", "POST", new JSONObject().put("printerId", "queue").put("format", "ZPL"), bearer, device, proof, null).statusCode());
assertEquals(403, send(client, base, prefix + "/device/jobs/" + job + "/cancel", "POST", new JSONObject(), bearer, device, proof, null).statusCode());
// The claim guarantees are unchanged: no claim before the realtime connection has applied current state.
assertEquals(409, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), bearer, device, proof, null).statusCode());
// Exactly one credential holds the channel: a bearer and a cookie together are refused.
assertThrows(Exception.class, () -> PrintingSocketFixture.connectBearer(client, base, bearer, cookie, device, proof));
assertThrows(Exception.class, () -> PrintingSocketFixture.connectBearer(client, base, bearer, null, device, "wrong-proof"));
try(PrintingSocketFixture channel = PrintingSocketFixture.connectBearer(client, base, bearer, null, device, proof))
{
assertEquals(job, json(send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), bearer, device, proof, null), 200).getJSONObject("job").getString("jobId"));
HttpResponse<byte[]> downloaded = send(client, base, prefix + "/device/jobs/" + job + "/content", "GET", null, bearer, device, proof, null);
assertEquals(200, downloaded.statusCode());
assertArrayEquals(content, downloaded.body());
JSONObject received = new JSONObject().put("eventId", "station-received").put("status", "RECEIVED");
json(send(client, base, prefix + "/device/jobs/" + job + "/events", "POST", received, bearer, device, proof, null), 200);
channel.send("JOB_EVENT", "station-spooled", new JSONObject().put("jobId", job).put("event", new JSONObject().put("eventId", "station-spooled").put("status", "SPOOLED")));
assertEquals("station-spooled", channel.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
assertEquals("SPOOLED", service.getJob(job).getString("status"));
assertTrue(json(send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), bearer, device, proof, null), 200).isNull("job"), "A received job is never handed out again");
// The credential is verified again on every operation: once it no longer names a station the channel ends.
auth.withModuleCodeReference(administratorModule);
channel.send("PING", "no-longer-a-station", new JSONObject());
assertEquals("AUTHORIZATION_REQUIRED", channel.take("ERROR").getJSONObject("payload").getString("code"));
assertEquals(1008, channel.closed.get(10, TimeUnit.SECONDS));
}
auth.withModuleCodeReference(new QCodeReference(StationAuthentication.class));
// An administrator revokes the device with an ordinary session; the identity ends at the provider and the station is refused.
auth.withModuleCodeReference(administratorModule);
json(send(client, base, prefix + "/admin/devices/" + device + "/actions", "POST", action("REVOKE"), null, null, null, cookie), 200);
assertEquals(List.of("operator"), identities.revoked);
auth.withModuleCodeReference(new QCodeReference(StationAuthentication.class));
assertEquals(403, send(client, base, prefix + "/device/heartbeat", "POST", new JSONObject().put("agentVersion", "test"), bearer, device, proof, null).statusCode());
assertThrows(Exception.class, () -> PrintingSocketFixture.connectBearer(client, base, bearer, null, device, proof));
}
finally
{
auth.withModuleCodeReference(administratorModule);
}
}
finally
{
launcher.stop();
}
}
/** The same verified account, holding only the station permission: what the host's policy gives a station identity. */
public static class StationAuthentication extends PrintingLocalAuthentication
{
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
QSession session = super.createSession(instance, context);
session.setPermissions(Set.of(PrintingStationAuthorization.PERMISSION));
return session;
}
}
/** Recording stand-in for the host's identity provider. */
private static final class Identities implements PrintingStationIdentities
{
private final List<String> issued = new ArrayList<>();
private final List<String> revoked = new ArrayList<>();
private final List<String> secrets = new ArrayList<>();
private boolean failIssue;
private boolean failRevoke;
private @Nullable String fixedPrincipal;
@Override
public Issued issue(String stationName)
{
if(failIssue)
{
throw new IllegalStateException("synthetic provider outage");
}
issued.add(stationName);
secrets.add("synthetic-secret-" + UUID.randomUUID());
return new Issued(fixedPrincipal != null ? fixedPrincipal : "station:" + stationName, "svc-print-" + stationName, secrets.getLast(), "2099-01-01T00:00:00Z");
}
@Override
public void revoke(String principalId)
{
if(failRevoke)
{
throw new IllegalStateException("synthetic provider outage");
}
revoked.add(principalId);
}
}
/** A station session as the host builds it from the identity provider's token: the station permission and the tenant key. */
private static QSession station(String principal, String tenant)
{
return new QSession().withUser(new QUser().withIdReference(principal)).withPermissions(PrintingStationAuthorization.PERMISSION).withSecurityKeyValue("printing_tenant", tenant);
}
/** What the print client sends with a code. */
private static JSONObject request(JSONObject code)
{
return code.put("deviceName", "DOCK-PC").put("agentVersion", "test");
}
/** The in-memory database of a shared fixture. */
private static String jdbc(PrintingProcessesTest.Fixture fixture)
{
return ((com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) fixture.instance().getBackends().values().iterator().next()).getJdbcUrl();
}
/** The HTTP status a refused call carries. */
private static int status(Executable call)
{
return assertThrows(PrintingException.class, call).getStatus();
}
/** Synthetic PDF job; no printer exists. */
private static String submit(PrintingService service, String device, String title) throws Exception
{
return service.submit(new JSONObject().put("deviceId", device).put("printerId", "laser").put("title", title).put("format", "PDF")
.put("contentBase64", Base64.getEncoder().encodeToString("%PDF-synthetic".getBytes(StandardCharsets.UTF_8))).put("idempotencyKey", title)).getString("jobId");
}
/** One audited device action. */
private static JSONObject action(String action)
{
return new JSONObject().put("requestId", UUID.randomUUID().toString()).put("issuedAt", Instant.now().toString()).put("action", action);
}
/** An administrator's ordinary sign-in on the local host. */
private static String login(HttpClient client, String base) throws Exception
{
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).header("Origin", base)
.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(("operator:" + PASSWORD).getBytes(StandardCharsets.UTF_8)))
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
return response.headers().allValues("set-cookie").stream().filter(value -> value.startsWith("sessionUUID=")).findFirst().orElseThrow().split(";", 2)[0];
}
/** Real HTTP with a bearer credential or a cookie, never both, plus the separate device proof headers. */
private static HttpResponse<byte[]> send(HttpClient client, String base, String path, String method, @Nullable JSONObject body, @Nullable String bearer, @Nullable String device, @Nullable String proof, @Nullable String cookie) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(base + path)).header("Origin", base).header("X-QQQ-Client", "api/qqq-print-agent");
if(bearer != null)
{
request.header("Authorization", "Bearer " + bearer);
}
if(cookie != null)
{
request.header("Cookie", cookie);
}
if(device != null)
{
request.header(PrintingAgentAuthorization.DEVICE_HEADER, device);
}
if(proof != null)
{
request.header(PrintingAgentAuthorization.TOKEN_HEADER, proof);
}
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString()));
if(body != null)
{
request.header("Content-Type", "application/json");
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
}
/** Check status before interpreting success; no secret-bearing body is echoed on failure. */
private static JSONObject json(HttpResponse<byte[]> response, int status)
{
assertEquals(status, response.statusCode());
return new JSONObject(new String(response.body(), StandardCharsets.UTF_8));
}
}
@@ -59,9 +59,10 @@ class PrintingUiApiContractTest
assertEquals(List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(baseline), 2,
"mvnw.cmd -Dtest=PrintingUiApiContractTest -Dprinting.openapi.update=true test"));
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertEquals(20, model.getPaths().size());
assertEquals(21, model.getPaths().size());
assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/")));
assertEquals("2.4.0", extension.getSemanticVersion());
assertEquals("2.5.0", extension.getSemanticVersion());
assertTrue(generated.contains("/qqq/v1/qbit/printing/stations/enroll"));
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("application/octet-stream"));
assertTrue(generated.contains("principalId"));