From 254f6fd918bca80d489ed3f4e496387982cfd68b Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 17:55:30 -0600 Subject: [PATCH] feat(station): a print station authenticates as its own identity A print station is a computer beside a printer with nobody signed in. Until now it ran under the session of the person who paired it and renewed that session every five minutes. A host that ends sessions after user inactivity would sign it out 15 minutes after every sign-in, and a named person's session running unattended is hard to defend. - printing_station: a permission that alone opens nothing. On the device routes, after the request proves the one device bound to the station's identity, the station may heartbeat, report printers, claim, download and acknowledge its own jobs and answer control commands. The proof lives in the request's QContext, tied to that session object, never on the session. Every row read must belong to that device. Processes and the administrative, operator and generic table routes stay closed. - printingEnrollStation creates a one-use, 15-minute station code. POST /qqq/v1/qbit/printing/stations/enroll redeems it with no session and answers once with the device proof and the identity's credential. A station code cannot pair a person and a person's code cannot create a station. - PrintingStationIdentities (issue, revoke) is the host's issuer, set with PrintingService.withStationIdentities. Without it there is no station enrollment (503). An identity issued for an enrollment that then fails is revoked. Revoking or removing a station's device revokes its identity; a provider failure answers 502 and the action can be repeated. - The realtime upgrade accepts exactly one credential: a session cookie, or, for a station only, a bearer token, verified again on every frame. - Two additive nullable columns: printing_enrollment.station_name and printing_device.station_name. Extension 2.5.0; wire v1 and the subprotocol are unchanged, and existing pairings behave as before. The claim and acknowledgement rules of realtime printing on several nodes are untouched. docs/STATION-IDENTITY.md describes the flow, the guarantees, the host contract, what the host, the identity QBit, the provider and the Windows client still need, the release order and the first enrollment on dev. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 25 + docs/OPERATIONS.md | 5 + docs/REALTIME.md | 5 + docs/STATION-IDENTITY.md | 245 ++++++++ .../printing/PrintingAgentAuthorization.java | 11 +- .../qbits/printing/PrintingApiSchemas.java | 4 + .../PrintingControlAuthorization.java | 11 +- .../qbits/printing/PrintingProcesses.java | 35 +- .../qbits/printing/PrintingQBitProducer.java | 4 +- .../printing/PrintingReadPermission.java | 21 +- .../printing/PrintingRealtimeRoutes.java | 43 +- .../qbits/printing/PrintingService.java | 217 ++++++- .../PrintingStationAuthorization.java | 127 ++++ .../printing/PrintingStationIdentities.java | 92 +++ .../qbits/printing/PrintingUiApi.java | 27 +- .../qbits/printing/PrintingUiApiSpec.java | 32 +- .../resources/openapi/qqq-v1/printing.yaml | 155 ++++- .../qbits/printing/PrintingSocketFixture.java | 17 + .../qbits/printing/PrintingStationTest.java | 584 ++++++++++++++++++ .../printing/PrintingUiApiContractTest.java | 5 +- 20 files changed, 1611 insertions(+), 54 deletions(-) create mode 100644 docs/STATION-IDENTITY.md create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingStationAuthorization.java create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingStationIdentities.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingStationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc2dd8..2cd1a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 793454b..1f15c42 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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 diff --git a/docs/REALTIME.md b/docs/REALTIME.md index c735110..3ea0872 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -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 diff --git a/docs/STATION-IDENTITY.md b/docs/STATION-IDENTITY.md new file mode 100644 index 0000000..95c176b --- /dev/null +++ b/docs/STATION-IDENTITY.md @@ -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:` (`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:`, not + `service:`. 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:`, full name `Print station `. 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--print-station` carrying that policy (generated into `gg-forge--roles.yaml` from + Forge's vendored policies), and an application access binding for it (`gg-forge-dev.yaml:398-409` binds only + `gg-forge--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=`, `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