WIP: feat(station): a print station authenticates as its own identity (printing_station, station enrollment code) #37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user