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