Skip to content

test: cover SSH identity mode end to end - #7363

Merged
otavio merged 5 commits into
test/e2e-device-pairingfrom
test/e2e-ssh-identity-mode
Oct 7, 2026
Merged

otavio merged 5 commits into
test/e2e-device-pairingfrom
test/e2e-ssh-identity-mode

Conversation

@otavio

@otavio otavio commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Covers Domain 13 (SSH Identity Mode) of shellhub-io/team#243 with testcontainers tests: 29 subtests over 28 of the 29 open items. Each slice runs on one shared stack.

tests/
├── environment/
│   ├── identity.go          # SQL back-dating: identity expiry, last use, last re-auth,
│   │                        #   approval expiry; a broken source_ip; DeleteAccessPolicy
│   └── server_log.go        # ServerLogMark + AwaitServerLogLine: one line, after a mark
├── identity_helpers_test.go # startLogin: an interactive client that reads and answers prompts
├── identity_connection_test.go     # expired, single-use, last use, principal binding
├── identity_policy_test.go         # non-member, CIDR, logins, tags, role, all-members, unevaluable deny
├── identity_reauth_test.go         # period nil, fresh/stale, window boundary, API keys, stricter period
├── identity_approval_test.go       # reject, TTL, expiry, re-enroll, permissions, visibility, anti-phishing
└── identity_reauth_approval_test.go # refreshes LastReauthAt, enrollment endpoint 403, timeout

Each refusal asserts how the server decided it, not just that the login failed:

  • the denial reason in the server log, on the same line as the user (reason=not_a_member user=<id>)
  • the approval state
  • the HTTP status
  • the exact reason the client was shown

Expiry and freshness cases back-date the timestamp in SQL. The window-boundary case also waits out the window in real time. No case deletes a row to fake an expiry.

A non-member can never hold an identity (ssh_identities references memberships and cascades), so "Non-member is refused" parks a login on an approval. A member confirms it and is removed before the terminal answers.

dialSSH now shares its handshake with the new interactive dialer. CreateAccessPolicy returns the policy it created.

Not covered: "Approval concurrency limit". The per-source cap from 98d60a1 was removed by d22b28d ("drop the per-source cap on parked approval waits"), so master has nothing to test.

Evidence

  • Before: the 28 items had no test. "Non-member is refused" was unticked by the 2026-10-02 audit because the only refusal case was a member with no grant.
    After: every new test passes, run one top-level test per process:

    TestSSHIdentityConnection   5/5
    TestAccessPolicyEvaluation  7/7
    TestAccessPolicyReauth      6/6
    TestSSHApprovalIdentity     8/8
    TestSSHApprovalReauth       3/3
    TestIdentityAccessPolicy    6/6  (dialSSH refactor)
    
  • The tests fail when the behaviour breaks. In two rounds I mutated the server code each subtest guards, then reverted it. Every subtest failed except the cross-principal one, which has no practical mutation and asserts user=<member id> directly. Some of the mutations:

    • dropping identity.Active from ResolveKeyAuth
    • letting an unevaluable deny continue
    • flipping stricterReauthPeriod
    • matching all-members for API keys
    • removing the SessionApprove check
    • skipping the single-use burn
    • making releaseSSHApproval succeed on a lapsed code

Merge Danger

Door: two-way

Test-only. Nothing under server/, pkg/ or ui/ changes.

Blast Radius: CI time

Five more stacks in the validate (tests - postgres) job, about 45 s each.

@otavio
otavio requested a review from a team as a code owner October 6, 2026 22:31
@otavio
otavio added this pull request to stack #7361 October 6, 2026 22:31
@otavio otavio changed the title test/e2e ssh identity mode test: cover SSH identity mode end to end Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 8m 51s —— View job


Code Review Complete

Reviewed 10 files. 3 inline issues posted. Standards: 0 violations, 2 judgement calls. Spec: 0. Correctness: 1.

Standards

No rule is broken. Every comment the branch adds is a doc comment on an exported declaration or a nolint reason, and the reasons behind the workarounds are in the commit messages. The bigger of the two judgement calls is the helper name grant: it also creates deny policies, so identity_policy_test.go:178 reads deny := grant(...) (tests/identity_helpers_test.go:349).

Correctness

The window-boundary subtest leaves the straight-through login only 5 seconds before the server's re-authentication check flips. On a slow CI runner that subtest can fail when nothing is wrong (tests/identity_reauth_test.go:54).

Spec

The Spec axis did not run. The PR closes no issue, and none of its commits carry an issue reference, so there was nothing to review against.

Additional notes

I dropped several lower-value judgement calls rather than posting them: data clumps in the login helpers, the hand-built enroll request, assertions on free-text log lines, and the wording of the forbidigo suppressions. The 5-call cap was not reached.


To request another review round, comment /review.

requireReauth(t, compose, "minutely", new(period))
signer, fingerprint := enrollOwnerKey(t, compose)

compose.AgeSSHIdentityReauth(t, fingerprint, (period-5)*time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: the straight-through login only has 5 seconds of margin. Back-dating last_reauth_at to period-5 leaves 5s for the identityByFingerprint GET, the TCP dial and the full SSH handshake before the server runs its >= check (server/ssh/session/auther.go:312). If that path takes longer than 5s on a loaded CI runner, the server asks for a re-authentication. requireStraightThrough then waits on a challenge nobody answers and fails spuriously.

The sleep on line 60 is computed from reauthedAt, the value read back from the database, so widening the margin only needs this constant to change:

Suggested change
compose.AgeSSHIdentityReauth(t, fingerprint, (period-5)*time.Second)
compose.AgeSSHIdentityReauth(t, fingerprint, (period-20)*time.Second)

@otavio
otavio requested a review from a team as a code owner October 6, 2026 22:40
Comment on lines +349 to +356
func grant(t *testing.T, compose *environment.DockerCompose, req *requests.AccessPolicyCreate) models.AccessPolicy {
t.Helper()

policy := compose.CreateAccessPolicy(t, req)
t.Cleanup(func() { compose.DeleteAccessPolicy(t, policy.ID) })

return policy
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standards (judgement call), Fowler Mysterious Name: grant creates any access policy and registers its deletion, whatever the action. One of the 14 callers passes Action: deny, so identity_policy_test.go:178 reads deny := grant(...). That call site says the opposite of what it does. A neutral name such as createPolicy or withPolicy would read correctly at all 14 call sites.

Comment on lines +44 to +55
func readServerLog(ctx context.Context, source LogSource) (string, error) {
reader, err := source.Logs(ctx)
if err != nil {
return "", err
}

defer func() { _ = reader.Close() }()

logs, err := io.ReadAll(reader)

return string(logs), err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standards (judgement call), Fowler Duplicated Code: readServerLog repeats the open → defer Close → io.ReadAll sequence that AwaitLogContains already has inline (docker_compose.go:142-151). Both are in package environment, so AwaitLogContains can call this function and the log-reading path lives in one place:

		logs, err := readServerLog(t.Context(), source)
		if !assert.NoError(tt, err) {
			return
		}
		assert.Contains(tt, logs, substr)

A name that doesn't say "server" would then fit better too, since LogSource is not tied to the server.

otavio added 5 commits October 7, 2026 11:47
An expired identity and a consumed single-use one are both refused when the key is offered, and
neither is sent back through enrollment: the key is known, only dead. A single-use identity
belongs to an API key, so that case grants the key through an api-key policy first. Every
connection stamps the identity's last use, and a member's key is decided as that member, which a
policy granting only the owner then refuses.

The API sets an identity's expiry only in whole days ahead, so ExpireSSHIdentity back-dates it in
SQL, a minute into the past so the server's own clock is past it too. AgeSSHIdentityLastUse moves
the last-use stamp a day back, so the next connection's stamp cannot be mistaken for it.

These subtests share one stack, so a server log line another subtest caused would satisfy a plain
substring search. ServerLogMark records how much the server has logged, and AwaitServerLogLine
reads only what follows and wants every field on one line, which ties a denial reason to the user
it was about. The cleanups that delete a policy or untag a device run on
context.WithoutCancel(t.Context()) because the test's context is cancelled before cleanups run.

startLogin drives a login the way a person at an interactive client does: it reads every
keyboard-interactive prompt and answers only the approval one, leaving the denial prompt
unanswered as the gateway expects. Its context is detached from the test and bounded at three
approval waits, so a login parked on a prompt outlives the subtest's assertions and the cleanup
cancels it and waits for the dial to return. ssh.NewClientConn ignores its context, so handshake,
now shared with dialSSH, closes the connection when the context ends. result puts the outcome back
on its channel so it can be read again.

CreateAccessPolicy now returns the policy it created, so a subtest can delete it by id when done.
…lifts

Each case connects as a member or API key that only its own policies grant, so the owner's
starter policy never decides it and the shared stack needs no policy deleted from under it.

A non-member cannot hold an identity: ssh_identities references memberships and cascades on
removal. The only way a non-member reaches the policy evaluation is a login parked on an
approval, so a member confirms it and is removed before the terminal answers. The server then
logs not_a_member for that user and the client is told an access policy does not allow the
login, even though an every-member policy would grant a member.

The source address case reads the address the gateway saw from the session an owner login
opens, because the client's address depends on how the host routes into the stack. It is
unmapped before it becomes a prefix, as the server unmaps it before matching. 203.0.113.0/24 is
TEST-NET-3, which no client comes from.

The API refuses a source entry that is not an address, so BreakAccessPolicySourceIP writes one
in SQL to stand in for a row that went bad. The same deny that does not apply while readable
refuses the owner once it cannot be evaluated.
A re-authentication is completed the way the console does it, by posting the owner's password,
the key's fingerprint and the approval code to /api/web-terminal/reauth, and typing the
confirmation code it returns at the terminal.

AgeSSHIdentityReauth back-dates the last re-authentication in SQL, standing in for the hour a
window takes to lapse. The window boundary case does not: it back-dates the stamp to five seconds
short of a one-minute period, logs in straight through, then sleeps until a second past the
period and is asked again. The server compares with >=, so the exact instant cannot be hit from
outside; the two logins sit on either side of it within the same window.

An API key subject with require_reauth goes straight through and is never stamped, which is the
server dropping the requirement for anything that is not a person. With two periods the shorter
decides, and a policy with no period outweighs any period.
The approval window is the server's SSHApprovalTTL constant, so ExpireSSHApproval back-dates the
row in SQL. One second is enough: the lookup compares expires_at with the server's clock, which
shares the database host's. The terminal then answers with 23456789, a code in the pairing
alphabet that is not the confirmation, so the refusal comes from the expiry or the rejection
rather than from a malformed answer.

A confirmation sends only expires_in. Marshalling requests.SSHApprovalConfirm also sends its
untagged Code field empty, which binds over the path parameter and fails validation.

Re-enrolling is reached by two logins with the same unknown key, each parked on its own approval.
Confirming both as the owner binds the key once and lets both in. Confirming the second as another
member fails with 409 and leaves that approval pending, because the identity and the decision are
written in one transaction.

An observer holds neither approve permission and gets 403 on confirm and reject. A user of another
namespace gets 404 on read, confirm and reject, so a code alone discloses nothing.
Proving the factor moves a day-old last re-authentication to now and marks the approval
confirmed. The identity enrollment endpoint answers 403 for a re-authentication approval and
leaves it pending, and the proper step-up then releases the same login.

Past its window, the step-up answers 404 and the identity's last re-authentication stays empty:
the stamp and the release are one transaction, so a step-up that released nothing refreshes
nothing.
@otavio
otavio force-pushed the test/e2e-ssh-identity-mode branch from d633cd8 to 569b0b0 Compare October 7, 2026 14:47
@otavio
otavio merged commit e1862d0 into master Oct 7, 2026
40 of 43 checks passed
@otavio
otavio deleted the test/e2e-ssh-identity-mode branch October 7, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant