Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/lifecycle-reasons.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,12 @@ Documented values:
`params` here is the same flat string map convention as
`WorkspaceEvent.params` — clients may localize the refusal instead of
parsing `message`, and unknown keys must be ignored.

## Portal session idle

`POST /v1/session:touch` is the portal's explicit activity beat: every
cookie-authenticated `GET` is passive, so the only signals that extend the
session's sliding idle window are mutations, desktop input (measured
broker-side), and this call — which the portal sends only on user
interaction (pointer, key, navigation), throttled to one per minute.
`GET /v1/session` stays anonymous and passive.
38 changes: 32 additions & 6 deletions docs/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,15 @@ Controls:
peer, or the right-most untrusted X-Forwarded-For entry when the peer
sits inside `-trusted-proxies` CIDRs (`ratelimit.go:174`,
`ParseTrustedProxies`).
- **Passive auth** — `GET /v1/connections/.../status` authenticates via
- **Passive auth** — since FIX-IDLE every cookie-authenticated `GET` mounts
`RequireAuthPassive`, which never extends the idle clock
(`internal/api/middleware.go:83-86`, `internal/api/connection_status.go:78`) —
A6-S6's second authenticated path exists to be reviewed.
(`internal/api/middleware.go`, and the `safe`/`RequireAuthPassive` mounts
in `workspaces.go`, `data.go`, `me.go`, `quota.go`, `adminquota.go`,
`adminuserlimit.go`): the portal's interval polls can no longer hold a
visible-but-unattended session open. Only mutations and server-measured
desktop input slide the window. `GET /v1/session` is anonymous (Peek) and
`GET /v1/connections/.../status` was already passive — A6-S6's second
authenticated path exists to be reviewed.
- **Tenant scoping** — the principal is built only from verified claims
(`internal/api/principal.go`); tenant-admin surface is scoped and events are
curated, not raw (`internal/api/events.go`, `statusview.go`;
Expand Down Expand Up @@ -562,9 +567,30 @@ Additional items found while writing this document (not from A6):
`ci.yml` job `partitioned`): real Set-Cookie attributes, in-frame
reconnect across a backend rollout, revoked-lease cookie rejection.
Same-site topology only; a cross-site deployment is not exercised.
- **Portal idle-extension depends on lease activity** — verify a stolen
portal cookie alone cannot extend itself, and that idle extension only
credits input activity measured server-side.
- **Portal idle-extension depends on lease activity** — Implemented
(FIX-IDLE): portal reads are all passive server-side, so no GET a client
can shape extends the idle window; extension only ever credits
(a) mutations — including the explicit activity beat
`POST /v1/session:touch` — passive auth + CSRF with the slide applied
explicitly only after the token check, so a cookie-only request never
earns a slide; login-family rate limit keyed on the session digest; the
SPA sends it on pointer/key/navigation events throttled to 1/min, never
from timer polls — and (b) RFB input measured broker-side. Forging a
touch requires the session cookie + CSRF token — the same bar as any
mutation, so the beat grants nothing a caller could not already do. That input touch
is now scoped to the bound portal session digest — the session the
stream's lease was minted under — rather than every session of the
principal (SR-1-F3; `TouchSessionDigest`,
`internal/store/sessions.go`), with the principal-wide path kept only
as the NULL-digest fallback for pre-binding leases. Lease
redeem/renew/rehydrate honour the portal idle window (SR-1-F2): the
liveness re-checks in `loadLease` and `RedeemTicket` consult
`last_seen_at` under `WithSessionIdle`, so an idled-out session's lease
is revoked at the next renew (reason `invalid`) and a stale cookie
cannot rehydrate it. Renew deliberately does NOT slide the window —
otherwise a connected-but-idle stream would pin the session open
forever — so an abandoned desktop tab dies with its portal session
inside one renew cycle.
- **Metrics listener** is scrape-only but has no auth — Implemented:
served on the dedicated ClusterIP `backend-metrics` Service; the only
rule opening the metrics port is `allow-metrics-scrape` admitting
Expand Down
6 changes: 3 additions & 3 deletions internal/api/adminquota.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,11 @@ func NewAdminQuotaHandler(src AdminQuotaSource, dir Directory, t TenantResolver,
}

// MountAdminQuotaRoutes registers the admin quota routes audited (see
// MountWorkspaceRoutes): RequireAuth+audit on the read, RequireAuth+audit+
// RequireCSRF on the write — the read is inside the wrapper too, so admin
// MountWorkspaceRoutes): RequireAuthPassive+audit on the read,
// RequireAuth+audit+RequireCSRF on the write — the read is inside the wrapper too, so admin
// API coverage is total: every /v1/admin/ request leaves an audit event.
func MountAdminQuotaRoutes(mux *http.ServeMux, authn *Authenticator, h *AdminQuotaHandler) {
mux.Handle(routeAdminQuotaGet, authn.RequireAuth(
mux.Handle(routeAdminQuotaGet, authn.RequireAuthPassive(
audited(h.audit, routeAdminQuotaGet, http.HandlerFunc(h.Get))))
mux.Handle(routeAdminQuotaSet, authn.RequireAuth(
audited(h.audit, routeAdminQuotaSet,
Expand Down
4 changes: 2 additions & 2 deletions internal/api/adminuserlimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,12 @@ func (h *AdminUserLimitsHandler) WithAuditSink(s observability.AuditSink) *Admin
}

// MountAdminUserLimitRoutes registers the routes audited (see
// MountAdminQuotaRoutes): RequireAuth+audit on the read,
// MountAdminQuotaRoutes): RequireAuthPassive+audit on the read,
// RequireAuth+audit+RequireCSRF on the writes — denied (non-admin,
// cross-tenant) attempts emit the route's table action with outcome
// denied; a write that decodes resolves to the set or clear variant.
func MountAdminUserLimitRoutes(mux *http.ServeMux, authn *Authenticator, h *AdminUserLimitsHandler) {
mux.Handle(routeAdminUserLimitsGet, authn.RequireAuth(
mux.Handle(routeAdminUserLimitsGet, authn.RequireAuthPassive(
audited(h.audit, routeAdminUserLimitsGet, http.HandlerFunc(h.Get))))
mux.Handle(routeAdminUserLimitsPut, authn.RequireAuth(
audited(h.audit, routeAdminUserLimitsPut,
Expand Down
3 changes: 3 additions & 0 deletions internal/api/auditroutes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
const (
auditActionSessionLogout = "session.logout"
auditActionSessionRevokeAll = "session.revoke_all"
auditActionSessionTouch = "session.touch"
auditActionWorkspaceCreate = "workspace.create"
auditActionWorkspaceStart = "workspace.start"
auditActionWorkspaceStop = "workspace.stop"
Expand All @@ -42,6 +43,7 @@ const (
// pattern can never drift from its action.
const (
routeLogout = "POST /v1/logout"
routeSessionTouch = "POST /v1/session:touch"
routeSessionRevokeAll = "POST /v1/me/sessions:revoke-all"
routeWorkspaceCreate = "POST /v1/workspaces"
routeWorkspaceDelete = "DELETE /v1/workspaces/{id}"
Expand Down Expand Up @@ -71,6 +73,7 @@ type auditedRoute struct {
// under /v1/admin/.
var auditedRoutes = map[string]auditedRoute{
routeLogout: {auditActionSessionLogout, ""},
routeSessionTouch: {auditActionSessionTouch, ""},
routeSessionRevokeAll: {auditActionSessionRevokeAll, ""},
routeWorkspaceCreate: {auditActionWorkspaceCreate, ""},
routeWorkspaceDelete: {auditActionWorkspaceDelete, "id"},
Expand Down
70 changes: 52 additions & 18 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ type SessionStore interface {
// (D18). principal is the "issuer|subject" owner string. Returns the
// number of sessions touched.
TouchPrincipal(ctx context.Context, principal string) (int64, error)
// TouchSessionDigest slides last_seen_at for exactly one session — the
// row keyed by digestHex (hex of SHA-256(session id), the form a lease
// records) — while it is still inside the idle window and before its
// absolute expiry (SR-1-F3). Returns the number of sessions touched
// (0 or 1).
TouchSessionDigest(ctx context.Context, digestHex string) (int64, error)
Delete(ctx context.Context, id string) error
}

Expand Down Expand Up @@ -288,6 +294,20 @@ func (s *InMemorySessionStore) TouchPrincipal(_ context.Context, principal strin
return n, nil
}

// TouchSessionDigest slides last_seen_at for the single session the
// digestHex row key names (SR-1-F3) — same liveness guards as
// TouchPrincipal, so an expired session is never revived.
func (s *InMemorySessionStore) TouchSessionDigest(_ context.Context, digestHex string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess := s.peekLocked(digestHex, s.now())
if sess == nil {
return 0, nil
}
sess.LastSeenAt = s.now()
return 1, nil
}

func (s *InMemorySessionStore) Delete(_ context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down Expand Up @@ -880,26 +900,33 @@ const inputTouchMinInterval = time.Minute
const inputThrottleMaxEntries = 10_000

// InputHook returns the broker input hook (broker.WithInputHook): each
// recorded "input" event slides the portal idle timer of the lease's
// principal — the Principal.Owner() string "issuer|subject" — throttled to
// one store write per principal per minute. Input never revives a session
// that already expired; the store's TouchPrincipal WHERE clause excludes
// sessions outside the idle window (D18).
func (a *Authenticator) InputHook() func(ctx context.Context, principal string) {
// recorded "input" event slides the portal idle timer of the session the
// input arrived under — the lease's bound portal_session_digest — throttled
// to one store write per key per minute (SR-1-F3). A legacy lease carrying
// no digest falls back to the principal-wide touch. Input never revives a
// session that already expired; the store's WHERE clauses exclude sessions
// outside the idle window (D18).
func (a *Authenticator) InputHook() func(ctx context.Context, principal, sessionDigest string) {
hook, _ := a.newInputHook(inputThrottleMaxEntries)
return hook
}

// newInputHook builds the throttled hook over an LRU of at most max
// principals; size reports the current entry count (tests).
func (a *Authenticator) newInputHook(max int) (hook func(ctx context.Context, principal string), size func() int) {
// keys — a session digest when the lease names one, else the principal —
// so a multi-session principal throttles per session, not per identity;
// size reports the current entry count (tests).
func (a *Authenticator) newInputHook(max int) (hook func(ctx context.Context, principal, sessionDigest string), size func() int) {
var mu sync.Mutex
order := list.New() // front = most recently seen; elements are *throttleEntry
byPrincipal := map[string]*list.Element{}
hook = func(ctx context.Context, principal string) {
byKey := map[string]*list.Element{}
hook = func(ctx context.Context, principal, sessionDigest string) {
key := "p:" + principal
if sessionDigest != "" {
key = "s:" + sessionDigest
}
now := a.now()
mu.Lock()
if el, ok := byPrincipal[principal]; ok {
if el, ok := byKey[key]; ok {
e := el.Value.(*throttleEntry)
order.MoveToFront(el)
if now.Sub(e.at) < inputTouchMinInterval {
Expand All @@ -908,15 +935,21 @@ func (a *Authenticator) newInputHook(max int) (hook func(ctx context.Context, pr
}
e.at = now
} else {
byPrincipal[principal] = order.PushFront(&throttleEntry{principal: principal, at: now})
byKey[key] = order.PushFront(&throttleEntry{key: key, at: now})
if order.Len() > max {
oldest := order.Back()
order.Remove(oldest)
delete(byPrincipal, oldest.Value.(*throttleEntry).principal)
delete(byKey, oldest.Value.(*throttleEntry).key)
}
}
mu.Unlock()
if _, err := a.sessions.TouchPrincipal(ctx, principal); err != nil {
var err error
if sessionDigest != "" {
_, err = a.sessions.TouchSessionDigest(ctx, sessionDigest)
} else {
_, err = a.sessions.TouchPrincipal(ctx, principal)
}
if err != nil {
a.log.Warn("session idle touch failed", "err", err)
}
}
Expand All @@ -928,11 +961,12 @@ func (a *Authenticator) newInputHook(max int) (hook func(ctx context.Context, pr
return hook, size
}

// throttleEntry is one LRU element: when the principal's session was last
// touched.
// throttleEntry is one LRU element: when this key's session(s) were last
// touched — "s:"+digest keys a single bound session, "p:"+principal the
// legacy principal-wide touch.
type throttleEntry struct {
principal string
at time.Time
key string
at time.Time
}

// groupsAllowed enforces RequiredGroups: with none configured the gate is
Expand Down
3 changes: 2 additions & 1 deletion internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func newTestEnvFull(t *testing.T, issuer func(*oidctest.Issuer), mutate func(*Au
MountRevokeAllRoute(mux, a)
MountMeRoutes(mux, a, NewMeHandler(testSessionDomain))
MountSessionProbeRoute(mux, a)
MountSessionTouchRoute(mux, a)
mux.Handle("/v1/echo-owner", a.RequireAuth(a.RequireCSRF(http.HandlerFunc(echoOwnerHandler))))

var h http.Handler = mux
Expand Down Expand Up @@ -456,7 +457,7 @@ func TestSessionIdleAndAbsoluteExpiry(t *testing.T) {
resp.Body.Close()
sess = findCookie(cookies, env.auth.SessionCookieName())
fc.Advance(30 * time.Second)
r = env.authedGet(t, sess, "/v1/me") // touches idle
r = env.authedGet(t, sess, "/v1/me") // passive read — does not touch idle
r.Body.Close()
if r.StatusCode != http.StatusOK {
t.Fatalf("second session rejected early: %d", r.StatusCode)
Expand Down
7 changes: 4 additions & 3 deletions internal/api/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,11 @@ func (h *DataHandler) WithDirectory(d Directory) *DataHandler {
return h
}

// MountDataRoutes registers the retained-data routes: RequireAuth on the
// list read, RequireAuth+RequireCSRF on attach/purge writes.
// MountDataRoutes registers the retained-data routes: RequireAuthPassive
// on the reads (the portal polls them), RequireAuth+RequireCSRF on
// attach/purge writes.
func MountDataRoutes(mux *http.ServeMux, authn *Authenticator, h *DataHandler) {
safe := func(h http.Handler) http.Handler { return authn.RequireAuth(h) }
safe := func(h http.Handler) http.Handler { return authn.RequireAuthPassive(h) }
// unsafe mounts an audited mutation route (see MountWorkspaceRoutes).
unsafe := func(pattern string, next http.Handler) {
mux.Handle(pattern, authn.RequireAuth(
Expand Down
Loading