12 KiB
SPEC-017: End-User (Sub-User) Remote Access
Status: Proposed Priority: P2 (may settle to P3 depending on client demand) Requested By: Mike (2026-06-02) Estimated Effort: Large
Overview
Let a client pay for their own employees to remotely reach their own work machines from home through GuruConnect — the Splashtop-Business / unattended-end-user-access model, layered on top of the MSP-technician console GuruConnect ships today. An MSP admin (or, later, a delegated client-company admin) provisions a list of end-users and grants each one access to specific managed machines. The end-user signs into a locked-down end-user portal, sees only the machines granted to them, and connects — reusing the existing persistent-agent + session-scoped-viewer-token + relay path.
Success criteria: an end_user-role account can log in at a separate portal, see exactly the machines
in its grant set (and no others, across no other tenant), launch a control session to an online granted
machine, and is hard-denied from every technician/admin API, the agent plane, and any machine it was
not granted — with each login and machine access written to the audit log.
This is a net-new sellable capability, not a console-MVP blocker. It is sequenced after the v2 console foundations it depends on (tenancy, machine identity, persistent enrollment), which is why it is P2 rather than P1.
Scope
Included in v1
- A new
end_uservalue forusers.role, provisioned by an MSP admin, with deny-by-default authority: no console permissions, no agent-plane access, machine reach limited strictly to itsuser_client_accessgrant set within its own tenant. - A separate end-user login + portal route (locked-down): lists only granted machines with online/offline state and a Connect action. No admin nav, no other users/machines/companies.
- Admin UI + API to create/disable end-users and assign/revoke per-machine grants, reusing the
existing
user_client_accesstable. - Connect flow that reuses the landed session-scoped viewer-token mechanism (
ViewerClaims,jwt.rs:114) and the relay enforcement path — no new transport. - A new
connect_sessions.sourcevalueend_user(migration widening the existing CHECK). - Audit: end-user login success/failure and each machine-access grant-check written to
connect_session_events. - Rate limiting + lockout on the public end-user login.
Explicitly out of scope (v1)
- Directory sync (AD / Entra-365 / Google) → end-user list — its own future spec; v1 is manual list management only.
- Self-service seat purchasing / billing automation. v1 records/counts seats per tenant; real metering and Syncro/billing wiring is deferred.
- Delegated client-company-admin role (a client managing its own end-users/grants) — noted as a fast-follow; v1 grants are MSP-admin-managed.
- Per-session view-only-vs-control policy per end-user (v1 = Control of one's own machine; the
ViewerAccesssplit still exists at the token layer). - File transfer, session recording (already out of scope for the broader product v1).
Architecture
Principal model — end_user is a constrained variant of the login plane
GuruConnect already has three credential planes that must stay separate (audit-hardened in v2 Phase 1):
- Login
Claims(jwt.rs:11) — dashboard users;role ∈ {admin, operator, viewer}today. - Session-scoped
ViewerClaims(jwt.rs:114) — 5-min, one session,purpose=viewer. - Agent
cak_keys (connect_agent_keys, migration 004) — agents only.
end_user is added as a fourth role on the login plane — it issues a normal login JWT
(create_token, jwt.rs:161) carrying role: "end_user" and an empty permission list. The
separation guarantees the v2 audit established are preserved: an end_user JWT still cannot be used as
a viewer token (lacks purpose) nor as an agent key (agent plane rejects user JWTs).
Critical authz inversion: user_client_access today documents "no entries = access to all (for
admins)" (migration 002, line 25-26). The grant check must branch on role — for end_user, an
empty grant set means zero machines, never all. Authz is deny-by-default and grant-scoped; the
admin-bypass in Claims::has_permission (jwt.rs:28-33) must never fire for end_user.
Agent / Relay-server / Viewer / Dashboard responsibilities
- Agent: no changes. End-users connect to existing persistent/unattended managed agents
(consent
not_required— it is the user's own machine). Optionally honors the SPEC-015 notification overlay if a per-machine policy requires it. - Relay-server: no transport change. New end-user auth + portal + connect endpoints; the grant-check + viewer-token mint is the only new server logic on the hot path.
- Viewer: reuse the React/TS web viewer (
dashboard/src/components/RemoteViewer.tsx) — the end-user portal embeds the same component with a Control-mode viewer token. - Dashboard: new role-gated end-user portal route (recommended separate from the technician console — see Open Questions), plus admin screens for end-user + grant management.
Database (migrations)
user_client_access— reused as the grant table; no schema change (alreadyuser_id UUID × client_id UUID → connect_machines(id), unique pair, migration 002).- New migration
011_end_user_access.sql:- Widen
connect_sessions.sourceCHECK to('standalone','gururmm','end_user')(currently('standalone','gururmm'), migration 004 line 99-102). - Optional
userscolumns for the external principal:mfa_secret TEXT NULL,must_change_password BOOLEAN NOT NULL DEFAULT false, and a partial index for fastrole='end_user'listing pertenant_id. - (Seat tracking, if landed in v1: a lightweight per-tenant
end_usercount view or atenant_seatsrow — kept minimal.)
- Widen
- Grants are tenant-contained: insert path validates
machine.tenant_id == end_user.tenant_id.
API endpoints / WS messages
POST /api/enduser/auth/login— public, rate-limited; returns anend_userlogin JWT.GET /api/enduser/machines— lists only the caller's granted, in-tenant machines + presence.POST /api/enduser/machines/:id/connect— grant-checked; creates asource=end_usersession and mints a ControlViewerClaimstoken (create_viewer_token,jwt.rs:233) for that session.- Admin:
POST /api/users(role=end_user),POST /api/users/:id/grants,DELETE /api/users/:id/grants/:machine_id,GET /api/users?role=end_user. - No new protobuf messages — the WS viewer path and
guruconnect.protoare unchanged.
Implementation details
server/src/auth/jwt.rs— extend the role vocabulary doc (Claims.role, line 16-17); add anis_end_user()helper and ensurehas_permissioncannot grantend_useranything beyond explicit permissions (the admin short-circuit at line 30 must be guarded).server/src/auth/mod.rs—AuthenticatedUser(line 29+) gains role-aware helpers; add an extractor / middleware that rejects non-end_useron the/api/enduser/*namespace and rejectsend_useron every console/admin route (deny-by-default allowlist).server/src/api/— newenduserhandler module (login, machines, connect); admin user+grant handlers extended forrole=end_useranduser_client_accesswrites.- Grant check (shared fn):
machine_id ∈ user_client_access[user] AND machine.tenant_id == user.tenant_id; used by bothGET /machinesandconnect. - Session create stamps
source='end_user',is_managed=true/unattended,consent_state='not_required', then mints the viewer token via the existing path so relay enforcement is unchanged. dashboard/src/— end-user portal route (role-gated), reusingRemoteViewer.tsx; admin grant-matrix UI. White-label (SPEC-014) applies to the portal as the most client-facing surface.- Migration
server/migrations/011_end_user_access.sqlas above (idempotent; applied bysqlx::migrate!per the migration standard).
Security considerations
- Preserve the plane separation audited in v2 Phase 1 —
end_useris login-plane only; it can never satisfyvalidate_viewer_tokenor the agentcak_path. - Deny-by-default, grant-scoped: empty
user_client_accessfor anend_user= no access; the admin-bypass must not apply. Every/api/enduser/*call re-checks the grant + tenant server-side (never trust a machine id from the client). - Tenant containment: an
end_userand its grants live in one tenant; cross-tenant grants are rejected at write and re-validated at connect. (Full tenant isolation lands with Phase 4; v1 enforces via explicittenant_idequality checks.) - External-user trust: these accounts are public-internet-facing from home. Require
rate-limiting + lockout on
/api/enduser/auth/login; support (recommend require) TOTP MFA forend_user— schema column included so MFA can be v1 or an immediate fast-follow without a second migration. Argon2id passwords (existing standard). - Audit: log each end-user login (success/failure, source IP) and each machine access to
connect_session_events; the unattended access is to the user's own machine but must be fully traceable. Optionally enforce the SPEC-015 overlay per machine policy. - Threat model: stolen end-user creds reach only that user's granted machines (blast radius =
grant set), never the console, never the agent plane, never another tenant. Disabling the account
(
users.enabled=false) immediately revokes portal + future tokens; the 5-min viewer-token TTL bounds any in-flight session.
Testing strategy
- Unit: grant-check fn (granted / not-granted / cross-tenant / empty-set-for-end_user = deny);
has_permissionnever elevatesend_user; role-namespace middleware (end_user→console = 403, technician→/api/enduser = 403). - Integration: end-user login → list shows only granted machines → connect mints a Control viewer
token for a
source=end_usersession → relay admits; connect to a non-granted / other-tenant machine → 403; disabled account → login + token use rejected. - Manual: full portal walkthrough from an off-network browser; MFA enrol + challenge; audit rows present for login and access; white-label branding renders on the portal.
Effort estimate & dependencies
- Size: Large (new principal + portal + admin grant UI + auth namespace; transport/agent untouched and the grant table already exists, which holds it below X-Large).
- Depends on (must precede / strongly preferred):
- Tenancy (
tenants+tenant_id, migration 004) — needed for containment; full isolation is Phase 4 but v1 uses explicit tenant checks. - Stable machine identity + persistent enrollment (SPEC-004 / 008
machine_uid, SPEC-016 zero-touchcak_) — end-users reach persistent managed agents. - Session-scoped viewer tokens (v2 Phase 1, landed) — reused directly.
- Tenancy (
- Pairs with: SPEC-014 (white-label — the portal is the client-facing surface), SPEC-003/005 (machine inventory/list — portal machine rows), SPEC-015 (optional connect-notification overlay).
- Unblocks: the directory-sync spec (AD/Entra/Google → end-user list), delegated client-admin role,
and per-seat billing — all of which build on the
end_userprincipal defined here.
Open questions
- Same console vs separate end-user portal? Recommendation: separate, role-gated route — smaller attack surface, no risk of leaking technician controls, cleaner white-label. Confirm before build.
- End-users in the existing
userstable (role=end_user) vs a dedicatedend_userstable? Recommendation: reuseusers(the grant FKuser_client_access.user_idalready points there) with hard role guardrails. Revisit if mixing external + internal principals in one table proves risky. - MFA in v1 or immediate fast-follow? Schema is included either way; decide enforcement timing.
- Who administers grants in v1 — MSP admin only (assumed), or ship the delegated client-company admin role together? (Affects scope/effort materially.)
- Seat/licensing enforcement depth for v1 — count-and-display vs hard-cap vs billing-integrated.
- Default access mode — Control assumed (own machine); should an admin be able to pin a machine to view-only for a given end-user? (Token layer already supports it.)