M2-40: Terminal rights, SR10, I6, SR12 and fresh step-up¶
Status: ready | Depends on: M1-49, M2-39 | Milestone: M2
Goal¶
After this ticket authorize decides the five terminal actions of 05 "Hosts and terminal" (terminal.open, terminal.attach, terminal.stream, terminal.read_own, terminal.close) instead of treating them as deferred entries. Only a human in a session reaches them; effective terminal comes from the owner rule or a grant on the host (07 "Hosts and the terminal capability"); SR10 keeps people who are not owners on the host assigned to them and leaves a server host to owners alone; SR11 lets only the holder of a personal LLM login open or attach (I6, also against other owners); SR12 demands an own session user whose name the host reports, also from owners; and step-up fresh demands a passkey or step-up device key confirmation not older than terminal.step_up_max_age that no other opening has consumed. The module identity records the consumption in auth_sessions.step_up_consumed_at under a lock on the session row inside the transaction of the action, so one confirmation opens exactly one session. I6 holds transactionally: a grant on a personal host to anybody but the holder, any group grant there, and any rights change that would leave such a grant valid roll back with 409 i6_violation; switching a host to personal or changing the holder of its LLM login ends every other person's session there in the same transaction, and so does a change of the assignee for the old assignee (SR10). The owner declares the LLM login with the new operation setHostLlmCredentials, the session users a host reports become part of the rights state (a change raises the rights version), and the preview of a grant on a host says when it would not take effect (hint terminal_not_effective). The routes that open and attach sessions come with M2-44; this ticket proves AZ-M54 to AZ-M61 (AZ-M60 with terminal.open itself), TM-T08, TM-T09, TM-T28 (a) and ID-T28 without the console part as decisions of authorize and through the identity and terminal use cases, which is the server side of T-TM-03, T-TM-08 and T-TM-09 (threat model 6.7), R-TM-05 to R-TM-07, R-TM-21 and R-TM-23.
Read first¶
docs/PLAN.md: section 1 "Principles", 3 "Way of working per ticket", 4 "Closing a milestone", 5 "Test strategy" (row "Security"); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part: rights with SR10 and I6), "Out of scope", "Exit criteria" items 8 and 10, "Test strategy", "Red team" (the terminal paragraph: opening without fresh or with consumed step-up, over group grants on a host withpersonal, by a second owner under I6, on the server host by people who are not owners, on foreign hosts despite a grant, without an own session user, by agent, app and personal tokens, sessions after every kind of revocation), "Dependencies", "Risks"; block "terminal" (normative sources and the order "Noise core, records, permissions, ...") with the rows M2-39 (what exists), M2-40 (this ticket, the contract), M2-41 (depends on this ticket), M2-43, M2-44, M2-46 and M2-48 (who builds what next); row M6-18 (the console part of ID-T28); section 19: P-04, P-09, P-10, P-11, P-12, P-20, P-24.- Dependencies: the header names M1-49 and M2-39. D-W-102 struck M1-35 (step-up with the device key) from the plan column of this ticket, because M2-39 implies it through M1-48 and M1-44; it stays in the read list:
docs/briefs/M1-35-identity-step-up.md. Through the two dependencies this ticket also builds on M1-48, M1-44, M1-34, M1-33, M1-32, M1-31, M1-29, M1-28, M1-27, M1-26, M1-25, M1-24, M1-20, M1-17, M1-16 and M1-13 (all implied, so not in the header). M2-39 had no committed brief when this brief was written; its plan row and its brief draft gave the names in Scope. Readdocs/briefs/M2-39-*.mdfirst if it exists when you start. docs/spec/24-terminal.md(English, normative): 1 "Purpose and limits"; 2.2 "Host servicebothy-host" (the list "One session user per person and host, never shared", points 1, 2 and 5); 2.3 "Server host"; 3.1 "States"; 6.1 "Opening" (steps 1 to 3); 6.2 "Detach and reattach" (the step-up bullet); 6.3 "Limits"; 7.1 "Why", 7.2 "Declaration", 7.3 "Invariant I6" (the three check points); 8 "Permissions" (8.1 to 8.4); 10.1 "Audit" (rowhost.manage, "Never in the audit"); 10.3 "Reasons for ending" (access_revoked,i6_violation); 13 "Errors"; 14 "Tests" (TM-T07 to TM-T09, TM-T12, TM-T17, TM-T28 and the sentence after the table); 15 "Milestones"; 16 (R-TM-01, R-TM-04 to R-TM-07, R-TM-21, R-TM-23); 17 (terminal.step_up_max_age,terminal.quick_reattach_window).docs/spec/07-permissions.md: 2.4 "Capabilities" (rowterminal), 2.5 "Validity of a grant", 2.12 "Hosts and theterminalcapability", 3 "Principal context", 4.1 "Decisions and reasons" (rowssubject_kind_not_allowed,step_up_not_possible,terminal_not_host_assignee,terminal_server_host_owner_only,i6_holder_only,i6_violation,terminal_no_session_user,terminal_profile_unknown,terminal_session_limit,terminal_step_up_not_fresh,host_not_active), 4.2 "Flow" (steps 1, 3, 5, 6, 8 and 9; the paragraph on deferred entries), 4.3 "Visibility" (host and terminal session), 4.4 "Invariants in the transaction", SR10, SR11, SR12, 7.2 "Permission state (snapshot)", 7.3 "Validity of the cache", 8.2 "Immediate effect on revocation" point 7, 9.3 "Plain-text preview", 11.1 "Test matrix" (AZ-M54 to AZ-M61), 11.2 (AZ-P1, AZ-P3, AZ-P8, AZ-P13), 12 (R-RE-11).docs/spec/08-identity.md: 6.1 "Two keys per native device", 6.2 "Step-up flow", 6.3 "Fresh step-up for the terminal", 6.4 "Command-line tools", 15 "Tests" (ID-T18, ID-T28), 16 (R-ID-16), 18 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only):auth_sessions(step_up_at,step_up_method,step_up_consumed_at,client_componentand the four table checks),grants(targethost, the checks of R-DM-35, the index ontarget_host_id, the paragraph onpermission_versionthat namesllm_credentials,credential_holder_id,assigned_user_id,reported_session_usersandhost_session_users),hosts,host_session_users,terminal_sessions; 6.3 "Lock order"; 6.8 "I6 check"; 8 (R-DM-35, R-DM-37, R-DM-39 to R-DM-41).docs/spec/05-actions.md(core contract, read only): rules 1.1, 1.6, 1.7 and 1.11; 2.1 (+terminal,assignee,host-self); 2.2 (rowshost,terminal_session); 2.4 (Step-upfresh); 2.5; table 4.2 (access.preview); 4.3 (user.enable); 4.4 (grant.create); 4.14 "Hosts and terminal" (every row and the paragraph after the table); 6 (host.updated,terminal_session.changed); 8 (R-AZ-23, R-AZ-24).docs/spec/10-backend-policy.md: 3 "Invariants" (I6 and the paragraph on Ruling T12), 14 "Audit trail".docs/spec/04-sync-protocol.md: 8.2 "What happens on a change" (entries withkind: "host"), 8.3.docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowsauthzwithHostSource,identity,terminal), 4.4 "Allowed dependencies" (rowsauthz,identity,terminal), 8.1 "Flow" (phase hooks), 9.2 and 9.3.docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "Nonull"), 5 "Errors" (points 3 and 7), 7 "Idempotency", 16 "Endpoints" (group "Hosts").docs/spec/19-security.md: SEC-TERM-3, SEC-TERM-5, SEC-TERM-10, SEC-TERM-12, SEC-AUD-5, SEC-LOG-2.docs/api/openapi.yaml: operationssetHostLlmCredentials,getHost,updateHost,createGrant,previewGrant,enableUser,createStepUpChallenge,completeStepUpWithDeviceKey, and the descriptions ofopenTerminalSessionandattachTerminalSession(built by M2-44); schemasHostLlmCredentialsSet,LlmCredentials,Host,HostSessionUser,HostReportedSessionUser,HostUpdatedEvent,HostUpdatedPayload,GrantCreate,Grant,GrantPreview,PreviewHint,AccessEffect,AccessTarget,AccessTargetKind,TargetKind,Capability,InvariantViolation,FieldError,Problem(x-problem-codesterminal_denied,i6_violation,step_up_required,host_unavailable,conflict,version_conflict,precondition_required; memberconflicts), parameterIfMatchRequired.docs/api/asyncapi.yaml: messageevent.host.updated(nothing on the terminal channels).- ADRs:
docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md(decision points 4 to 7, both addenda of 2026-10-11, "Confirmation"), 0020 (step-up), 0024 (I1 without owner exception), 0017, 0018, 0053, 0070 (terminal in the web console: what is not built here). docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57 and T13 as D-W-62 in section 9), D-T25 to D-T29, D-T33, D-T36, D-T40, D-L22, D-W-63, D-W-66, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows. No other D-W ruling names M2-40.docs/security/threat-model.md: 5.16 "Terminal" (rows "Permissions" and "Device"), 6.7 "Terminal" (T-TM-03, T-TM-08, T-TM-09, T-TM-11).docs/RISKS.md: RK-22.docs/OPEN-QUESTIONS.md: OQ-03 and OQ-04 (default answers; they concern M2-45 and M2-46, not this ticket), OQ-24 (terminal only over a private network; default no; it changes M2-43 and M2-44, not this ticket).docs/operations/configuration.md:terminal.step_up_max_age,terminal.quick_reattach_window,auth.step_up_max_age,audit.reason_max_chars,audit.deny_aggregate_window,host.os_user_max_chars.docs/operations/tailscale.mdsection 3 (only for the network point).- Briefs: M1-25 (
ActionSpec.StepUpFresh), M1-28 (requirement table and atoms, deferred entries, visibility table, resolution, decision mapping,Resource, the fake sources and the random-world generator of AZ-P8), M1-29 (RightsChange,AddAffected,VerifyInvariants,InvariantError,DiffVisible, the deny audit entry), M1-24 (auditing.Recorder,KnownReasons), M1-34 (actor fieldsStepUpAt,StepUpMethod,ClientComponent, the Authenticator lookup, the migration ofauth_sessions), M1-35 (portStepUp), M1-31 (user.enable), M1-44 (personal tokens), M1-48 (CreateGrant), M1-49 (PreviewGrant,domain/preview.go,domain/previewtextwithcatalog_de.go,catalog_en.goand the golden files), M1-13 (unit of work). - Code on the base branch:
internal/authz(snapshot, cache, requirements, gate, guards, sources,VerifyInvariants, preview),internal/kernel/authorize,internal/kernel/actor,internal/identity(step-up, Authenticator, sessions,Directory,identitytest),internal/terminalwithterminaltest(M2-39),internal/kernel/auditing,internal/kernel/eventlog,internal/kernel/txn,internal/kernel/clock,internal/kernel/errs,internal/kernel/textcheck,internal/platform/pg/pgtest,internal/wire(configkeys_*.go,configchapters.go),api/operations.yaml,architecture/modules.yaml,migrations/. docs/quality/gates.md,docs/quality/red-team-gate.md,docs/quality/push-discipline.md,CONTRIBUTING.md.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- The five terminal actions of 05 4.14 become real catalogue requirements and leave the deferred list of M1-28 (07 4.2, AZ-P3):
terminal.open(human and +terminal, resourcehost, Step-upfresh, Audityes,rl.terminal),terminal.attach(self and +terminal, resourceterminal_session, Step-upfresh, Audityes,rl.terminal),terminal.stream(self, Auditdenials,rl.terminal),terminal.read_own(self or owner, resourcesterminal_sessionandhost,rl.read),terminal.close(self or owner, Audityes,rl.write). - Agents, apps and personal tokens reach none of these actions:
403 forbiddenwithreasonsubject_kind_not_allowed, audited, the same answer for every host or session id (05 4.14 paragraph after the table, R-AZ-23, AZ-M59, TM-T17, T-TM-09). - Effective
terminal(P, H)(07 2.12 point 3): P is an active owner in a session and not (H.llm_credentials = personaland P is notH.credential_holder_id); or a valid grant (07 2.5) with target H andterminalnames P or one of P's valid groups. A host lies outside the tree; an instance grant gives no terminal, and a host that is not visible givesnot_found(07 2.12 point 1, R-AZ-24, AZ-M60). - SR10: a person who is not an active owner gets
terminal.openandterminal.attachonly on a host assigned to them (hosts.assigned_user_id), elseterminal_not_host_assignee; on a server host (hosts.declared_server_hostorhosts.reported_is_server_host) only owners, elseterminal_server_host_owner_only; when the assignee changes, the sessions that SR10 no longer allows end in the same transaction withaccess_revoked(07 SR10, D-T1, TM-T08, AZ-M55, SEC-TERM-10). - SR11 (I6): on a host with
llm_credentials = personalonlycredential_holder_idopens or attaches, no other owner either (i6_holder_only) (07 SR11, D-T2, AZ-M56, TM-T09). - SR12:
terminal.openandterminal.attachonly with a valid assignment of the person to a session user of the host (host_session_users,revoked_at IS NULL) whoseos_useris inhosts.reported_session_users, owners included, elseterminal_no_session_user. Assignments and reported names are part of the rights state, and a change of the report raises the rights version (07 SR12, 03grantsparagraph onpermission_version, D-W-63, AZ-M61, TM-T28 (a)). - Step-up
fresh(05 2.4, 07 4.2 step 8, 08 6.3 points 1, 2, 4 and 5):step_up_atnot older thanterminal.step_up_max_age,step_up_methodpasskeyordevice_key, not consumed (step_up_consumed_at IS NULL OR step_up_consumed_at < step_up_at); the action consumes it in its own transaction under the lock of the session row, so one confirmation opens exactly one session; other step-up actions neither consume nor check the consumption;terminal.attachfrom the same device in the same login session withinterminal.quick_reattach_windowafter the detachment needs no step-up, never in the console; otherwise401 step_up_requiredwith audit reasonterminal_step_up_not_fresh(AZ-M58, ID-T28, R-ID-16, R-TM-07, SEC-TERM-3). - I6 transactionally (03 6.8, 24 7.3 point 1, 10 "Invariants"): after
grant.createwith targethostand afterhost.manage(declaration, holder, confirmation, state) no valid grant on a host withllm_credentials = personalnames another subject than the usercredential_holder_id; group grants there are always rejected; owners do not count. A violation rolls back with409 i6_violation(decisioninvariant_violation,hint) and one deny audit entry in its own transaction (AZ-M57, TM-T09). - Switching a host to
personalor changing the holder of its LLM login ends every non-closed session of another person on the host in the same transaction withclose_reason = 'i6_violation'(24 7.3 point 3, 03 6.8, 07 8.2 point 7). setHostLlmCredentials(PUT /api/v1/hosts/{host_id}/llm-credentials, actionhost.manage: owner, Step-upyes, Audityes, Permissionsyes,rl.admin,If-Matchrequired): declarationnone,personalororganization,credential_holder_idexactly withpersonal, a mandatory reason; audit details after 24 10.1 (operationllm_credentials, old and new values as codes, reason), eventhost.updated, rights version (24 7.2, 05 4.14).access.previewof a grant on a host (previewGrant): hintterminal_not_effectivewhen SR10 or SR12 keep the grant from taking effect, an I6 violation as an entry ofinvariant_violationswithallowed = false, effects with host entries, plain text from the template catalogue (07 2.12 point 4, 07 9.3, D-T1).- Decision to problem (07 4.1, 24 13):
terminal_not_host_assignee,terminal_server_host_owner_only,i6_holder_only,terminal_no_session_user,terminal_profile_unknownandterminal_session_limitgive403 terminal_deniedwithreasonnot_host_assignee,server_host_owner_only,i6_holder_only,no_session_user,profile_unknownandsession_limit;terminal_step_up_not_freshgives401 step_up_required;host_not_activegives409 host_unavailable;i6_violationgives409 i6_violationwithhintandconflicts.audit_events.deny_reasonkeeps the code of 07 4.1. - No JSON
null(ADR 0053); audit details, events and problem bodies of this ticket carry IDs and codes and the mandatory reason ofhost.manageonly, never terminal content, host or session-user names, tokens, challenge values or signatures (24 10.1 "Never in the audit", SEC-LOG-2, SEC-AUD-5).
Terminal security requirements (D-W-101):
- Rights per machine: the additional right
terminalon the targethost, SR10, SR11 with I6 and SR12 (07 "Hosts and theterminalcapability"). This ticket enforces all of them inauthorizeand the I6 check in the rights-change hook: the requirement+terminalbehind the subject gate, SR10 with the server host and with the end of sessions when the assignee changes, SR11, SR12 with the reported names in the rights state, and the transactional I6 check with the end of foreign sessions. Proved byTestAZ_M54_*toTestAZ_M61_*,TestTMT08_*,TestTMT09_*,TestTMT28a_*,TestSR10_AssigneeChangeEndsSessions,TestSR12_ReportDropsNameandTestI6_*. M2-39 owns the targethostitself,host.read,host.manage, the subject gate, the assignments and thehost.readpart of AZ-M60; M2-46 ends sessions after every other kind of revocation (TM-T10). - Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). This ticket enforces the rule in
authorizeand the consumption inidentity(StepUp.ConsumeFresh); a console session fails closed until M6-18 adds the passkey rule. Proved byTestFreshStepUp,TestQuickReattach,TestAZ_M58_*andTestIDT28_*(ID-T28 without the console part). M1-35 built the step-up with the device key; M2-44 callsConsumeFreshin the transactions ofopenTerminalSessionandattachTerminalSessionand proves TM-T07 and TM-T12 over HTTP; M6-18 owns the console part of ID-T28, TM-T07 and TM-T12 (reason: passkey_required); M4-36 and M5-12 use the step-up key in the apps. - Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile
tailscalereaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03,docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket adds no listener, no connection and no network code; its only new routesetHostLlmCredentialssits behind the authenticated HTTP chain like every API route (TestAZP1_TerminalAuthzRoutes: the route is registered with its action and class, and this ticket enables no terminal stream or host connection route). M2-43 builds the outbound control connection and M2-44 the data connection and the relay; an answer "yes" to OQ-24 changes those two tickets, not this one. - Audit without content: the entries this ticket writes or causes (denials of the terminal actions by
authorize,host.managefor the declaration withno_changewhere it applies, the deny entry ofi6_violation,terminal.terminatethroughEndSessionsof M2-39) carry IDs, codes and the mandatory reason only (TestTerminalAuthzAudit_NoContent). M2-46 owns the audit and metrics of sessions without content (TM-T06), M2-44 the audit ofterminal.openandterminal.attachwithpin_changedandforeign_assignee. - Idle timeout and maximum duration (24 "Limits"): not enforced by this ticket. M2-45 measures idle on the host, M2-46 adds
terminal.idle_timeoutplusterminal.idle_graceandterminal.max_session_durationin the server and the jobterminal.sweep(TM-T11). The quick reattach of this ticket applies only to adetachedsession withinterminal.quick_reattach_windowand extends no limit (TestQuickReattachcovers that onlydetachedqualifies). - Service user and session users without administrator rights (T12, HD rules): this ticket enforces the server side of T12: no terminal without an own session user that the host reports, also for owners (SR12), and the report is rights state (
TestAZ_M61_*,TestTMT28a_*,TestSR12_ReportDropsName). M2-39 keeps a session user with one person forever (409 conflictwithreason: session_user_taken), M2-42 buildsinstallanddoctorwith HD1 to HD11 (TM-T13, TM-T22, TM-T24), M2-45 runs every PTY in the helper of the session user named byosu. - Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-03 (opening through a stolen session, a foreign host or the server host) by step-up
freshper opening and SR10:TestAZ_M54_*,TestAZ_M55_*,TestAZ_M58_*,TestAZ_M60_*,TestTMT08_*,TestIDT28_ConcurrentOpeningsOneConfirmation(thehost.readpart of AZ-M60 in M2-39, TM-T07 in M2-44); T-TM-08 (a foreign LLM login through the terminal) by I6 and SR12:TestAZ_M56_*,TestAZ_M57_*,TestAZ_M61_*,TestTMT09_*,TestTMT28a_*,TestI6_UserEnableRevivesGrant(TM-T27 and TM-T29 in M2-42 and M2-45); T-TM-09 (prompt injection opens a terminal) by the subject gate:TestAZ_M59_*,TestSubjectGate_TerminalActions(TM-T17 over HTTP in M2-48); T-TM-11 (sessions after a revocation) for the three revocations built here:TestI6_HolderChangeEndsForeignSessions,TestSR10_AssigneeChangeEndsSessionsandTestSR12_ReportDropsName(every other kind in M2-46). Not touched here: T-TM-01 and T-TM-02 (M2-38, M2-44, M2-45), T-TM-04, T-TM-05 and T-TM-12 to T-TM-14 (M2-42, M2-43, M2-45), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-10 (M4-35).
Scope¶
All paths under services/server/ unless noted. Words: the assignee is hosts.assigned_user_id (07 "host assignee"; M2-39 calls it the holder of the host); the holder is hosts.credential_holder_id, the holder of a personal LLM login. The specification is in place from planning (07 2.12 and 4.1, 08 6.3, 24 7 and 8, setHostLlmCredentials, PreviewHint.code terminal_not_effective, the problem codes); change it only where Design notes say so or where the implementation proves it wrong.
What this ticket expects from M2-39 (expected names; where the merged code names them differently, follow the code and list the difference, see Boundaries): the module internal/terminal with the port Hosts (List, Get, Create, Update, SetState, DTOs and HostView), domain (NewOSUser, CloseReason, the Go mirror of host_reported_session_users.v1 in reported.go), app (outgoing ports Store and People, view.go, hostsource.go, and EndSessions(ctx context.Context, sel SessionSelector, reason domain.CloseReason) (int, error) with SessionSelector{HostID, UserID ids.ID}, which runs only inside the caller's unit of work and writes terminal.terminate), adapters/postgres, adapters/httpapi and the test fixtures terminaltest; the tables hosts, host_session_users, terminal_sessions and the foreign key grants.target_host_id; in internal/authz the source HostSource (Host, HostIDs, AssignedTo) with HostFacts{ID, State, AssignedUserID, LLMCredentials, CredentialHolderID, DeclaredServerHost, ReportedIsServerHost}, the snapshot map Hosts, the effective function TerminalOn, the atoms HumanSession, Assignee and Terminal, the gate before resolution for requirements of the form All(HumanSession, ...), host entries in DiffVisible, the affected principals of a host target, createGrant with target host; the exact comparison of a name with hosts.reported_session_users behind session_user_not_on_host; the errors[].code unknown_user; the writer of host.updated. Add whatever of this is missing and is needed below, in the module that owns it, and list it in the report.
Create:
internal/authz/domain/terminal.go: pure functionsFreshStepUp(f FreshFacts, now time.Time, maxAge time.Duration) bool,QuickReattach(f ReattachFacts, now time.Time, window time.Duration) bool,TerminalGuard(f TerminalGuardFacts) (Reason, bool)(Reasonas indomain/reasons.go) andTerminalEffective(s Snapshot, hostID ids.ID) bool(Design notes 4 to 7); the fact typesFreshFacts{StepUpAt, StepUpConsumedAt time.Time; StepUpMethod, ClientComponent string},ReattachFacts{Action authorize.Action; PrincipalDeviceID, PrincipalSessionID ids.ID; ClientComponent string; Session authorize.TerminalFacts},TerminalGuardFacts{PrincipalID ids.ID; Owner bool; Host HostFacts; SessionUser *SessionUserFact}. Use the fact types where M2-39 put them;domainnever importsappor an adapter.internal/authz/domain/i6.go: pureCheckI6(host HostFacts, grants []HostGrant, members map[ids.ID][]ids.ID) (I6Conflict, bool)withHostGrant{SubjectKind string; SubjectID ids.ID}for the grants already filtered to the valid ones (Design note 9).internal/authz/domain/previewtext/testdata/example_host_de.goldenandexample_host_en.golden(Design note 13).internal/terminal/domain/llm.go:Declaration{Credentials string; HolderID ids.ID}, pureValidateDeclaration(d Declaration) []FieldError(the field error type that M2-39's domain uses for400 validation_failed) andEndsForeignSessions(before, after Declaration) bool(Design note 10).internal/terminal/domain/report_change.go: pureSameReport(a, b []ReportedSessionUser) boolandVanishedNames(before, after []ReportedSessionUser) []stringover M2-39's mirror type (Design note 8).internal/terminal/app/llm_credentials.go: use caseSetLlmCredentialsand the in-transaction functionApplyLlmDeclarationthat M2-41 reuses forconfirmHost(Design note 10).internal/terminal/app/report.go: use caseRecordSessionUserReportand the in-transaction functionApplySessionUserReportthat M2-41 (pairHost) and M2-43 (report) call (Design note 8).internal/terminal/adapters/httpapi/llm_credentials.go: strict-server methodsetHostLlmCredentials.internal/terminal/adapters/postgres/queries/llm_credentials.sqlandreport.sql: update of the declaration with version check, update ofreported_session_users, the valid assignments of one person with the reported names of their hosts, the hosts withllm_credentials = 'personal'; regenerate sqlc.internal/identity/app/stepup_fresh.goand the queriesLockSessionForStepUpConsumptionandConsumeStepUpininternal/identity/adapters/postgres/queries/stepup.sql(Design note 4).- Migration
migrations/<next free number>_identity_step_up_consumed.sqlonly if the merged migration of M1-34 lacksauth_sessions.step_up_consumed_ator the checkstep_up_consumed_at IS NULL OR step_up_at IS NOT NULL; it adds exactly what is missing, as 03 has it.
Change:
internal/kernel/actor: fieldStepUpConsumedAt time.Timeon the request principal (zero: never consumed), with a doc comment; the Authenticator of M1-34 readsauth_sessions.step_up_consumed_atin its one lookup query.internal/kernel/authorize:Resource.HostID ids.ID(path hint of aterminal_session: its host),Resource.Terminal *TerminalFactswithTerminalFacts{SessionState string; SessionDeviceID, SessionAuthSessionID ids.ID; DetachedAt time.Time}(required forterminal.attach, Design note 4), andDecision.QuickReattach bool(true exactly when the step-up rule of afreshaction was waived by the quick reattach).internal/authz: the requirement entries of the five actions instead ofDeferredand the two rows of the "Permission" text mapping (Design note 1), visibility and resolution ofterminal_session(note 3), thefreshbranch of step 8 (note 4), the guard ofterminal.openandterminal.attach(note 5), the snapshot fieldsSessionUsersand, for effective owners,PersonalHosts(notes 6 and 7),TerminalEffectiveinDiffVisibleand in preview effects (note 7), I6 inVerifyInvariantsandInvariantError(note 9), the decision mapping rows (note 12), the host part ofPreviewGrantand the catalogue keys indomain/previewtext/catalog_de.goandcatalog_en.go(note 13), the golden list of deferred actions without the five ids.internal/authz/sources.go:HostSourcegainsPersonalHosts(ctx context.Context) ([]HostFacts, error)(hosts withllm_credentials = personal, ascending id) andSessionUsersOf(ctx context.Context, userID ids.ID) ([]SessionUserFact, error)withSessionUserFact{HostID, HostSessionUserID ids.ID; OSUser string; Reported bool};HostFactsgainsName string(for the preview text). The grant store gainsActiveGrantsOnHost(ctx context.Context, hostID ids.ID) ([]domain.Grant, error)(indextarget_host_id) if missing. Update the fakeHostSourceininternal/authz/adapters/fakeand the shared contract test of the source port. The port (root package) gains the DTOFreshStepUpFacts{StepUpAt, StepUpConsumedAt time.Time; StepUpMethod, ClientComponent string}and the interfaceFreshStepUpCheck{ Fresh(f FreshStepUpFacts, now time.Time) bool }.authz/appimplements it by mapping todomain.FreshFactsand callingdomain.FreshStepUpwithterminal.step_up_max_age.identitynever importsinternal/authz/domain(02 4.1 rule 2).internal/identity: portStepUpgainsConsumeFresh(ctx context.Context, a actor.Principal) error(actor.Principalstands for the request principal type ofkernel/actor); theStepUpuse case receives anauthz.FreshStepUpCheckfrominternal/wire.internal/terminal: portHostsgainsSetLlmCredentials(ctx context.Context, p actor.Principal, cmd SetLlmCredentials) (HostView, error)withSetLlmCredentials{HostID ids.ID; IfMatch int64; LLMCredentials string; CredentialHolderID ids.ID; Reason string};app.EndSessionsgainsSessionSelector.ExceptUserID ids.ID(zero: nobody excepted); the outgoing portPeoplegainsIsActiveOwner(ctx context.Context, userID ids.ID) (bool, error), bound ininternal/wiretoidentity.Directory.User(true exactly whenUserView.IsOwnerandStatus = active; it calls noauthorize), like M2-39's binding ofPeople.User; the use caseUpdateof M2-39 ends sessions on a change of the assignee (Design note 11);hostsource.goimplements the two newHostSourcemethods andName.internal/wire: configuration keysterminal.step_up_max_ageandterminal.quick_reattach_window(durations, defaults from 24 17, chapter constant of 24 inconfigchapters.goif missing) passed toauthz;internal/wirepasses the authzFreshStepUpChecktoidentity; the binding ofPeople.IsActiveOwnertoidentity.Directory.api/operations.yaml: movesetHostLlmCredentialsfromlaterto the enabled list; rerun the route generator of M1-17.docs/api/openapi.yaml(spec first, English): description ofsetHostLlmCredentials(holder rules,409 conflictfor aretiredhost, an unchanged declaration answers200without a new version,409 i6_violationwithconflicts); description ofHostLlmCredentialsSet.credential_holder_id(errors[].coderequiredwhen missing withpersonal,not_allowedwhen sent withnoneororganization,unknown_userwhen it names no human or a deleted one);Problem.conflicts"present withi1_violation(one entry per agent) andi6_violation(one entry per host)";InvariantViolation.credential_holder_idandprincipal_idsfor I6 (Design note 9);PreviewHintdescription forterminal_not_effective(principal_idfor a user subject,group_idandcountfor a group subject). Thenmake api-lint,make api-checks,make api-tablesand the server code generation.docs/spec/07-permissions.md(English, like the chapter): 4.2 the order of SR10, SR11 and SR12 in step 9 and the owner rule of the requirement+terminal(Design notes 1 and 5); SR10 the end of sessions on a change of the assignee (note 11); 9.3 the preview of a grant on a host with the German example of Design note 13.docs/spec/08-identity.md6.3 point 4: the consumption checks the locked row again and is the first row lock of the action's transaction (Design note 4).docs/spec/24-terminal.md7.3 "Enforcement points" point 1: I6 is verified with every rights change, which coversuser.enableof a person whose dormant grant would become valid again (Design note 9); 7.2 the holder rules and theretiredrule (Design note 10); 13 rowvalidation_failed: theerrors[].codenot_allowed(andunknown_userif M2-39 has not added it there).docs/operations/configuration.md: regenerate withmake config-referenceif the schema text changes.- READMEs of
internal/authz,internal/identityandinternal/terminal;services/server/CHANGELOG.mdunder Unreleased;docs/DECISIONS.mdsection 10 rowsD-M2-40-<n>for every Ruling below.
This ticket creates no new top-level make target and a migration only under the condition above.
Out of scope¶
- The target
host, the tables,host.read,host.manage, the subject gate,listHosts,createHost,getHost,updateHost(except Design note 11),setHostState, the assignments of session users with409 conflictandsession_user_not_on_host,EndSessions, the transitions of 24 3.1 and thehost.readpart of AZ-M60 (M2-39). - Pairing,
issueHostPairingCode,pairHost,confirmHostand the entryhost.pair(M2-41;confirmHostreusesApplyLlmDeclarationand the I6 check of this ticket,pairHostcallsApplySessionUserReport). - The control connection,
report, presence andconnected, the entryhost.connect(M2-43; itsreportcallsRecordSessionUserReport). openTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSession,closeTerminalSession, the session token, the call ofConsumeFresh, the audit ofterminal.openandterminal.attach(pin_changed,foreign_assignee), the check that a stream comes from the device of the current token, the guardshost_not_active(stateactiveand connected) andterminal_profile_unknown(profile and working directory), the eventterminal_session.changed, TM-T07 and TM-T12 over HTTP (M2-44).terminal_session_limit; ending sessions after every other kind of revocation, among them a change of the owner status or ofreported_is_server_hostthat SR10 no longer allows (subscribers ofauthorize.AccessChanged,identity.DeviceRevoked,identity.SessionEnded); metrics; TM-T06, TM-T10, TM-T11 (M2-46); TM-T17 in the suite (M2-48).- The console: passkey only with
userVerification = required,reason: passkey_required, the console part of ID-T28, TM-T07 and TM-T12 (M6-18); console sessions (M6-01). - The branch of SR10 for an own ready dev environment (
hosts.dev_environment_id, V2); until then it is false. - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-40-terminal-authz; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(authz): ...,feat(identity): ...,feat(terminal): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief and generated references. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for M2-39: its merged names win over the expected names here; list each difference. If the base branch lacks the tables
hosts,host_session_usersorterminal_sessions, orauthz.HostSource, M2-39 is not merged: stop withVerdict: BLOCKED <reason>. - No host service, no relay, no real device and no Noise handshake in the tests; sessions in the tests are rows written through
terminaltest. - Time only through
kernel/clock; tests move a fake clock and never sleep for a window. - Step-up challenge values, signatures, access tokens and API tokens never appear in logs, audit details, problem bodies, events or test failure messages.
Design notes¶
-
Entries and atoms (05 4.14, 07 4.2, M1-28 requirement table, M2-39 atoms). Mapping rows for the two new texts of 05:
human and +terminalisAll(Human, Terminal),self and +terminalisAll(Self, Terminal); the source test of M1-28 compares them with 05. Overrides by action id (they win over the text rule and put every action behind the gate of M2-39, which evaluatesHumanSessionbefore resolution and answersforbiddenwithsubject_kind_not_allowedfor agents, apps and personal tokens):terminal.open:All(HumanSession, Terminal)onhost.terminal.attach:All(HumanSession, Self, Terminal)onterminal_session;Terminalis evaluated on the session's host.terminal.stream:All(HumanSession, Self);terminal.close:All(HumanSession, Any(Self, Owner)).terminal.read_own:All(HumanSession, ByKind{terminal_session: Any(Self, Owner), host: Auth}). Ruling for docs/DECISIONS.md: on a host, "self or owner" means "own sessions on a host you can see, owners all"; the list query of M2-44 restricts the rows to the caller'suser_idfor people who are not owners, so on the host only visibility counts.Terminalstays M2-39'sTerminalOn(an effective owner, or a grantterminalin effect); its owner part ignores I6, and SR11 (Design note 5) denies an owner who is not the holder withi6_holder_only(AZ-M56), as M2-39 ruled. If the base branch lacks the gate, add it as M2-39 describes (after 07 4.2 step 1, before snapshot, resolution and visibility).- Remove the five ids from the golden list of deferred actions;
host.pairandhost.connectstay deferred for M2-41 and M2-43.VerifyRegistrationsthen accepts routes with the five actions once M2-44 enables them (AZ-P1).
-
Personal tokens. A personal token is a human without a session; the gate refuses it with
subject_kind_not_allowed, so the step-up branch never answersstep_up_not_possiblefor the five actions (05 4.14 paragraph after the table). -
Resolution and visibility (07 4.3, M1-28 resolution). A
hostresource resolves throughHostSource.Host, fresh per call (reported_is_server_hostchanges without a new rights version, 03grantsparagraph). Aterminal_sessionresource comes fromterminalasResource{Kind: terminal_session, ID, HostID: terminal_sessions.host_id, OwnerID: terminal_sessions.user_id, Terminal: &TerminalFacts{...}};authzresolves the host throughHostID(zeroHostIDis an internal error, an unknown hostnot_foundwithno_grant) and treats the session as visible whenOwnerIDis the principal or the principal is an effective owner, elsenot_foundwithnot_self(the principal-owned kinds of M1-28). Replace the internal error of the visibility rowterminal_session. -
Step-up
fresh(05 2.4, 07 4.2 step 8, 08 6.3). WhenActionSpec.StepUpFreshis set, step 8 evaluates only this branch, not theauth.step_up_max_agebranch:QuickReattachfirst: actionterminal.attach,ClientComponentnotconsole,Session.SessionState = detached,Session.SessionDeviceIDequal to the principal'sDeviceID,Session.SessionAuthSessionIDequal to the principal'sSessionID, andnow - DetachedAtnot greater thanterminal.quick_reattach_window(equal is allowed, one nanosecond more is not). When it holds, step 8 passes without step-up and the decision carriesQuickReattach = true; the use case then consumes nothing.FreshStepUpotherwise:StepUpAtset andnow - StepUpAtnot greater thanterminal.step_up_max_age(equal allowed),StepUpMethodpasskeyordevice_key,StepUpConsumedAtzero or beforeStepUpAt, andClientComponentnotconsole. Failing any of them gives decisionstep_up_requiredwith reasonterminal_step_up_not_fresh. Ruling for docs/DECISIONS.md: a console session fails closed until M6-18 adds the passkey rule withuserVerification = required(08 6.3 point 3); console sessions do not exist before M6-01.identity.StepUp.ConsumeFresh(ctx, p)joins the ambient unit of work (none is an internal error), locks the session row withSELECT ... FOR UPDATE(idanduser_idof the principal), answers401 session_revokedfor a revoked or expired session, re-evaluates freshness on the locked row's values throughauthz.FreshStepUpCheck(the samedomain.FreshStepUpthat step 8 uses) with the clock of the call, and on success setsstep_up_consumed_at = now. On failure it returns the same error thatDecision.Errgives for decisionstep_up_requiredwith reasonterminal_step_up_not_fresh(401 step_up_requiredwithoutreasonin the problem body), and the audit reason stays available to the caller. It writes no event and no audit entry; the use case of the action (M2-44) records the opening and the denial. Ruling for docs/DECISIONS.md: the check runs again on the locked row, not on the actor of the request, because two requests with one confirmation both passauthorizeand only the lock orders them;ConsumeFreshis the first domain row lock of the action's transaction, so 03 6.3 holds and the second opening sees the consumption. Update 08 6.3 point 4.- Other step-up actions keep the
auth.step_up_max_agebranch and never readstep_up_consumed_at(08 6.3 last paragraph).
-
Guards SR10, SR11, SR12 (07 4.2 step 9; 24 6.1 step 2) for
terminal.openandterminal.attach, in this order, first failure wins:- SR10 server host: the principal is not an effective owner and the host has
DeclaredServerHostorReportedIsServerHost:terminal_server_host_owner_only. - SR10 assignee: the principal is not an effective owner and
AssignedUserIDis not the principal (the branch of an own ready dev environment is false until V2):terminal_not_host_assignee. - SR11:
LLMCredentials = personaland the principal is notCredentialHolderID, owners included:i6_holder_only. - SR12: no
SessionUserFactof the principal for this host in the snapshot, or one withReported = false, owners included:terminal_no_session_user. - Extension point after SR12, documented, no empty stubs: M2-44 adds
host_not_activeandterminal_profile_unknown, M2-46terminal_session_limit(order of 24 6.1 step 2).
Ruling for docs/DECISIONS.md: SR10 before SR11 before SR12, the order of 07 section 5 and 24 6.1 step 2, and within SR10 the server host first, because a server host is closed to every person who is not an owner, assigned or not, so its reason is the one that tells the person what would help. The guards run after step 8 as 07 4.2 orders them (
TestTerminalDecisionOrder): a person without a fresh step-up learnsstep_up_requiredfirst. - SR10 server host: the principal is not an effective owner and the host has
-
Rights state per person: session users (07 SR12, PLAN row). The snapshot of a human principal gains
SessionUsers map[ids.ID]SessionUserFact(key: host id) fromHostSource.SessionUsersOf, which returns the valid assignments (revoked_at IS NULL) withReportedcomputed by the exact comparison of M2-39 (os_userequal to a reported name, case included; 24 5.3 comparesosuexactly). Every change ofhost_session_users(M2-39) and ofreported_session_users(Design note 8) raises the rights version, so the cache of 07 7.3 stays correct without a new key. -
Effective
terminalin the diff and in the preview (07 2.12 point 3, 04 8.2).TerminalEffective(s, h)isTerminalOn(s, h)without the owner part whens.PersonalHosts[h]names another holder than the principal;PersonalHosts map[ids.ID]ids.ID(host to holder) is filled for effective owners fromHostSource.PersonalHosts.DiffVisiblecompares the host sets throughTerminalEffectiveinstead ofTerminalOn, so a switch topersonalgivesremoved: [{kind: host, id}]for every other owner and a switch back givesaddedwithcapabilities: [terminal]; preview effects use the same diff. Ruling for docs/DECISIONS.md: the I6 clause of 07 2.12 point 3 is part of the diff because it depends on the declaration, which raises the rights version (03); SR10 and SR12 are guards, not part of effectiveterminal, and never appear in the diff, because 04 8.2 reports the additional right itself and SR10 depends onreported_is_server_host, which raises no rights version. M2-39's ruling that a state or assignee change writes noaccess.changedstays true. -
The report as rights state (03
grantsparagraph, 07 SR12, 24 2.2 point 5).RecordSessionUserReport(ctx, cause authorize.Action, hostID ids.ID, report []domain.ReportedSessionUser) (ReportOutcome, error)withReportOutcome{Changed bool; EndedSessions int}:- Validate each entry with M2-39's decoder of
host_reported_session_users.v1andNewOSUser(pattern, at mosthost.os_user_max_chars; display names withtextcheck); an invalid report writes nothing and returns a validation error to the caller. - Compare with the stored value through
SameReport(equal when the set ofos_usernames and each name's orderedworkdirsare equal; the order of the names does not matter). Equal: returnChanged = false, no transaction, no rights version. Ruling for docs/DECISIONS.md: a host repeats its report after every reconnect, and a version bump per reconnect would empty every rights cache. - Different: one unit of work with
RightsChange{Action: cause, Resource: host, Targets: [host], Principals: people with a valid assignment on the host}, thenApplySessionUserReport: lock the host rowFOR UPDATE, compare again, writereported_session_users, callEndSessions(SessionSelector{HostID, UserID: person}, access_revoked)for every person whose assigned name is inVanishedNames(07 SR12: "and so does a report of the host that no longer lists that name"), and writehost.updated. Nohost.manageaudit entry here:host.pair(M2-41) andhost.connect(M2-43) audit their own calls.
- Validate each entry with M2-39's decoder of
-
I6 in
VerifyInvariants(03 6.8, 24 7.3, 07 4.4). In phase 3.1 of every rights change, for every host fromHostSource.PersonalHosts, read in the ambient transaction: load the grants on the host (ActiveGrantsOnHost), keep those valid att(07 2.5: not revoked,expires_ataftert, subject active, group not deleted), and callCheckI6. A valid grant withsubject_kind = groupis always a violation, members or not; a valid grant to a user other thanCredentialHolderIDis a violation; owners do not count. Ruling for docs/DECISIONS.md: the check runs on every rights change like I1 (ruling of M1-29), becauseuser.enable(M1-31) of a person whose grant on a host became dormant before the switch topersonalwould make a grant to another person valid again, and 03 6.8 names only the actions where a violation starts from the request; update 24 7.3 point 1.InvariantErrorgains codei6_violationwith hintRevoke the other grants on this host or declare organization first.and conflict entries rendered asInvariantViolation{code: i6_violation, host_id, credential_holder_id, principal_ids, hint};principal_idsare the user subjects other than the holder plus the members in effect of offending group grants, sorted and unique (empty for a group without members). IDs only, never names.- Several violations in one change: the problem code is the first of
i1_violation,i6_violation,last_owner, andconflictslists the entries of that code. Ruling for docs/DECISIONS.md. - The deny audit entry follows M1-29 with
details.violationsof the I6 entries; the preview of M1-49 calls the same evaluator, so I6 appears ininvariant_violationswithallowed = false.
-
setHostLlmCredentials(24 7.2 and 7.3, 05 4.14host.manage), use caseSetLlmCredentials, answers in M2-39's order:authorize(p, host.manage, host): gate (403 forbiddenwithreasonsubject_kind_not_allowed), unknown or invisible host (404 not_found, same body), not an owner (403 forbiddenwithreasonowner_only), step-upyes(401 step_up_required); denials are audited byauthz.- Body:
reasonas M2-39'supdateHostchecks it (textcheck, at mostaudit.reason_max_chars;400 validation_failedwitherrors[].coderequiredortoo_longand pointer/reason).ValidateDeclaration:personalwithoutcredential_holder_idgives400 validation_failedwitherrors[].coderequiredand pointer/credential_holder_id; a holder withnoneororganizationgives400 validation_failedwitherrors[].codenot_allowed; a holder that is not a human principal or isdeleted(People.User) gives400 validation_failedwitherrors[].codeunknown_user. Ruling for docs/DECISIONS.md: the codenot_allowedis new and documented inHostLlmCredentialsSetand 24 13. If-Matchas in M2-39: missing gives428 precondition_required, different gives412 version_conflictwithcurrent; then decide "No effect" in a read before any rights change: samellm_credentialsand holder as stored gives200with the current host, no new version, no event, no rights version, and one audit entry withno_change: true(06 2).- A
retiredhost gives409 conflict, as M2-39 answersupdateHoston it (24 3.1:retiredis final). Ruling for docs/DECISIONS.md: every other state accepts a declaration;confirmHost(M2-41) sets it again at the confirmation. - Otherwise one unit of work with
RightsChange{Action: host.manage, Resource: host, Targets: [host], Principals: old and new holder}andApplyLlmDeclaration: lock the host rowFOR UPDATEafterpermission_version(03 6.8), compare the version again (412on a race), writellm_credentials,credential_holder_id(NULL unlesspersonal),llm_credentials_declared_by,llm_credentials_declared_at,version + 1; whenEndsForeignSessions(before, after)(the new value ispersonaland the old one was not, or the holder changed) callEndSessions(SessionSelector{HostID, ExceptUserID: new holder}, i6_violation); writehost.updated; write the audit allow entryhost.managewith the flat detailsoperation = llm_credentials,from_llm_credentials,to_llm_credentials,from_credential_holder_idandto_credential_holder_id(absent when none) andreason. Phase 3.1 then runs I6; a violation rolls everything back, sessions included, and answers409 i6_violation. - Answer
200with theHostViewandETag.
-
SR10 on a change of the assignee (07 SR10: "Checked ... on every change of
assigned_user_id...: if the condition no longer holds, the sessions end"; PLAN row "a change of the assignee ends other people's sessions in the same transaction"). In M2-39'sUpdate, inside its unit of work, whenassigned_user_idchanges or is cleared and the old assignee is not an active owner (People.IsActiveOwner), callEndSessions(SessionSelector{HostID, UserID: old assignee}, access_revoked). Sessions of owners and of the new assignee stay. Ruling for docs/DECISIONS.md: only the old assignee can lose SR10 by this change (every other person who is not an owner was already refused by SR10); a change of the owner status or ofreported_is_server_hostis M2-46's. -
Decision mapping (
Decision.Err, M1-28 table; 07 4.1, 24 13):Outcome Reason Problem Extension forbiddenterminal_not_host_assignee403 terminal_deniedreason: not_host_assigneeforbiddenterminal_server_host_owner_only403 terminal_deniedreason: server_host_owner_onlyforbiddeni6_holder_only403 terminal_deniedreason: i6_holder_onlyforbiddenterminal_no_session_user403 terminal_deniedreason: no_session_userforbiddenterminal_profile_unknown403 terminal_deniedreason: profile_unknownforbiddenterminal_session_limit403 terminal_deniedreason: session_limitforbiddenhost_not_active409 host_unavailablenone step_up_requiredterminal_step_up_not_fresh401 step_up_requirednone ( passkey_requiredcomes with M6-18)invariant_violationi6_violation409 i6_violationhint,conflictsThe rows for
terminal_profile_unknown,terminal_session_limitandhost_not_activeare added now so that the one table stays complete; M2-44 and M2-46 add the guards that produce them.audit_events.deny_reasonkeeps the 07 code (M1-24KnownReasonsalready lists them; add any that is missing). -
Preview of a grant on a host (07 2.12 point 4 and 9.3, M1-49).
previewGrantwithtarget_kind = hostruns the same dry run:effects: host entries from the diff of Design note 7,before_levelandafter_levelnone,capabilities_added: [terminal].invariant_violations: the I6 entries of Design note 9;allowed = falsewith any of them.hints:terminal_not_effectivewithprincipal_idfor a user subject when, after the change, SR10 or SR12 of Design note 5 would deny that person on the host; for a group subject one hint withgroup_idandcount= number of active members in effect whom SR10 or SR12 would deny, absent whencountis 0. Noterminal_not_effectivehint when an I6 violation names the same host.group_reachandexpiry_missingapply as in M1-49;read_horizonandtrigger_blocked_tool_classnever apply to a host.- Text lines for a host grant: summary, then denial, I6 violation,
terminal_not_effective, group reach, missing expiry. No abilities line and no capabilities line: they describe levels in the tree. The cause ofterminal_not_effectivefor a user subject is the first failing guard: server host, assignee, session user. - New catalogue keys (placeholders in braces;
{host}isHostFacts.Name,{holder}the display name of the holder):
Key de en summary.host{subject} erhält{until} das Zusatzrecht "Terminal" auf dem Rechner {host}.{subject} gets the additional right "Terminal" on host {host}{until}.hint.i6Abgelehnt: Auf {host} gilt die persönliche LLM-Anmeldung von {holder}. Dort darf nur {holder} eine Freigabe haben.Rejected: {host} uses the personal LLM login of {holder}. Only {holder} may hold a grant there.hint.terminal_not_effective.server_hostHinweis: Die Freigabe wirkt für {subject} nicht. {host} betreibt den Server; dort öffnen nur Owner ein Terminal.Note: the grant does not take effect for {subject}. {host} runs the server; only owners open a terminal there.hint.terminal_not_effective.not_assigneeHinweis: Die Freigabe wirkt für {subject} nicht. Auf {host} öffnen nur Owner und die Person, der der Rechner zugewiesen ist, ein Terminal.Note: the grant does not take effect for {subject}. On {host} only owners and the person the host is assigned to open a terminal.hint.terminal_not_effective.no_session_userHinweis: Die Freigabe wirkt für {subject} erst, wenn {subject} auf {host} einen eigenen Sitzungsbenutzer hat.Note: the grant takes effect for {subject} only once {subject} has an own session user on {host}.hint.terminal_not_effective.group(count > 1)Hinweis: Die Freigabe wirkt für {count} Mitglieder von {group} auf {host} nicht.Note: the grant does not take effect on {host} for {count} members of {group}.hint.terminal_not_effective.group_oneHinweis: Die Freigabe wirkt für 1 Mitglied von {group} auf {host} nicht.Note: the grant does not take effect on {host} for 1 member of {group}.Golden file
internal/authz/domain/previewtext/testdata/example_host_de.golden(exactly 3 lines):Sam erhält das Zusatzrecht "Terminal" auf dem Rechner R1. Hinweis: Die Freigabe wirkt für Sam nicht. Auf R1 öffnen nur Owner und die Person, der der Rechner zugewiesen ist, ein Terminal. Hinweis: Die Freigabe für Sam hat kein Ablaufdatum.Golden file
example_host_en.golden:Sam gets the additional right "Terminal" on host R1. Note: the grant does not take effect for Sam. On R1 only owners and the person the host is assigned to open a terminal. Note: the grant for Sam has no expiry date.World of both golden tests: owner (locale
deorenrespectively, time zoneUTC); host R1 assigned to Alex,organization, not a server host; Sam without grants; grant: Sam, host R1,terminal, no expiry, reasonCover. Ruling for docs/DECISIONS.md: the host templates, the cause order and the omitted lines; add the German example to 07 9.3. -
Rulings for docs/DECISIONS.md, one row each with reason: notes 1 (
terminal.read_ownon a host), 4 (console fails closed; consumption on the locked row), 5 (order), 7, 8 (unchanged report), 9 (every rights change; code priority), 10 (not_allowed;retired), 11, 13. Facts already in the specification (the rules of SR10 to SR12, I6, the columns) and the rulings of M2-39 (the gate, the owner part ofTerminalOn, the exact comparison) are not repeated there.
Tests (write them first)¶
Use the fake sources and the controllable clock of M1-28 for domain and app tests, and PostgreSQL 18 through pgtest with real authz, identity, terminal, audit recorder and event appender for integration tests. Test names carry the IDs; if M1-28 or M1-29 established another form, follow it. World (07 11.1 plus hosts through terminaltest): the Owner (owner, locale de, time zone UTC); Alex, Sam and Robin as people; group Leser with member Sam; host R1 (active, assigned to Alex, organization, reported session users bothy-s-alex, bothy-s-sam), host R2 (active, personal, holder Robin, reported bothy-s-robin, bothy-s-owner), host R3 (active, assigned to Sam, declared_server_host = true, reported bothy-s-sam, bothy-s-owner); assignments Alex bothy-s-alex and Sam bothy-s-sam on R1, Robin bothy-s-robin and the Owner bothy-s-owner on R2, Sam bothy-s-sam and the Owner bothy-s-owner on R3; grants terminal to Alex and to Sam on R1 and to Sam on R3; the agent Lotse (an agent actor in the app tests). Every person has a session with a fresh device_key step-up unless a test says otherwise. A person's status is changed through the identity test helper where a test needs it (user.disable is M1-45, outside the dependency closure).
Unit (internal/authz/domain, internal/terminal/domain):
TestFreshStepUp: table withdevice_keyandpasskeyfresh (allowed);password_totpandoidc_reauthfresh (denied); age exactlyterminal.step_up_max_age(allowed) and one nanosecond more (denied); no step-up;StepUpConsumedAtequal toStepUpAt(denied); consumed before a newer step-up (allowed);ClientComponent = consolewith a fresh passkey (denied).TestQuickReattach: same device and login session,detached,DetachedAtexactlyterminal.quick_reattach_windowago (true) and one nanosecond more (false); another device; another login session; stateactive; actionterminal.open; console (false in every case except the first).TestTerminalGuard: each rule alone and the order of Design note 5: a person who is not an owner on a server host assigned to them givesterminal_server_host_owner_only; not assigned givesterminal_not_host_assignee; owner on R3 passes SR10; owner who is not the holder on R2 givesi6_holder_only; holder Robin passes SR11; no assignment, an assignment withReported = false:terminal_no_session_userfor an owner and for an assignee; dev environment branch false.TestTerminalEffective: owner onorganization(true), owner onpersonalas holder (true) and not holder (false), person with a grant (true), without (false).TestCheckI6:organizationhost (none);personalwithout grants; with a grant to the holder (none); to another user, to an owner who is not the holder, to a group with and without members (violation with theprincipal_idsof Design note 9).TestDecisionErr_TerminalReasons: every row of the table in Design note 12 (problem code, status,reason,hintandconflictswhere named); the audit reason stays the 07 code.TestRequirementSources_Terminal(the source test of M1-28 forhuman and +terminal,self and +terminaland the overrides) andTestDeferredActionsGolden(golden without the five ids, withhost.pairandhost.connect).TestPreviewTextExampleHostGermanandTestPreviewTextExampleHostEnglish: render the golden world and compare byte for byte;TestPreviewTextHostVariants: each catalogue key of Design note 13 in both languages, the cause order, no abilities line,hint.i6with the holder's display name.TestValidateDeclaration(each rule of Design note 10),TestEndsForeignSessions(topersonal, holder change, back toorganization, unchanged),TestSameReportandTestVanishedNames(reordered names equal, reorderedworkdirsdifferent, removed and renamed names).
Decisions of authorize (internal/authz/app, fakes, world above):
TestAZ_M54_AlexOpensOwnHost: Alex, R1,terminal.open, fresh step-up:allow.TestAZ_M55_SamOnForeignHost: Sam with grant and assignment on R1:forbiddenterminal_not_host_assignee.TestAZ_M56_SecondOwnerOnPersonalHost: Robin is made a second owner; the Owner, R2, with assignment and fresh step-up:forbiddeni6_holder_only; Robin:allow.TestAZ_M57_GroupGrantOnPersonalHost:VerifyInvariantsafter a grantterminalto group Leser on R2: violationi6_violationwithhost_idR2 andprincipal_ids[Sam]; grants that are revoked, expired exactly att, of a disabled person or of a deleted group are ignored (the HTTP part isTestTMT09_I6).TestAZ_M58_ConsumedStepUp: Alex's step-up consumed for another opening:step_up_requiredterminal_step_up_not_fresh.TestAZ_M59_AgentToken: Lotse (agent) on R1 and on a random host id:forbiddensubject_kind_not_allowedboth, one deny entry each.TestAZ_M60_InstanceGrantGivesNoTerminal: a host R5 (active, no assignee,organization, row AZ-M60 of 07 11.1 as M2-39 corrects it), Alex with a grant on the instance withtriggerand all capabilities and no grant on R5:terminal.opengivesnot_found, the same answer as for a random host id.TestAZ_M61_OwnerWithoutSessionUser: the Owner, R1, fresh step-up, no assignment on R1:forbiddenterminal_no_session_user.TestTMT08_SR10: Sam on R1 (terminal_not_host_assignee), Alex on R1 (allow), Sam on R3 (terminal_server_host_owner_only); the same withreported_is_server_hostinstead of the declaration; the Owner on R3 (allow).TestTMT28a_NoSessionUser: the Owner and Alex (assignment ended) on R1: bothforbiddenterminal_no_session_user, each with an aggregated deny audit entry (AZ-P13);Decision.Errgives403 terminal_deniedwithreason: no_session_user.TestTerminalDecisionOrder: Sam on R1 without step-up getsstep_up_requiredbeforeterminal_not_host_assignee; Sam with a session but without any grant on R2 getsnot_found(R2 not visible); the Owner without step-up on R2 getsstep_up_requiredbeforei6_holder_only.TestTerminalAttachStreamReadClose: on a session of Alex on R1: Alexterminal.attachwith fresh step-up (allow), with quick reattach (allow,QuickReattach = true), from another device without step-up (step_up_required); the Ownerterminal.attach(forbiddennot_self),terminal.closeandterminal.read_own(allow); Sam on Alex's session for all four actions (not_found); Alexterminal.stream(allow);terminal.read_ownon host R1 for Sam (allow); missingResource.Terminalonterminal.attachand missingHostIDare internal errors.TestSubjectGate_TerminalActions: agent, app and a personal token of Alex on the five terminal actions:forbiddensubject_kind_not_allowed, also for unknown ids.TestTerminalSnapshotVersion: the snapshot cachesSessionUsersandPersonalHosts; a new rights version rebuilds them, the same version reuses them (count source calls).TestAZP8_TerminalMatchesReference: extend the random-world generator of AZ-P8 with declarations, server host flags, assignments and reports, and compareterminal.openandterminal.attachdecisions with cache against the reference without cache.
Integration (PostgreSQL 18 via pgtest, HTTP through the real chain, people and sessions from the identity test helpers, steerable clock; terminal sessions are rows written through terminaltest):
TestIDT28_PasswordTotpAndOidcStepUp: rows withstep_up_methodpassword_totpandoidc_reauth(no operation sets them before M6-01; write them with the identity test helper), read through the real Authenticator:authorize(terminal.open)givesstep_up_requiredterminal_step_up_not_fresh.TestIDT28_StepUpTooOld: step-up throughcompleteStepUpWithDeviceKey(M1-35), clock moved pastterminal.step_up_max_age:step_up_required; one nanosecond before:allow.TestIDT28_TwoOpeningsNeedTwoConfirmations: open harness (authorizethen a unit of work withConsumeFresh) succeeds once; the second attempt with the next request's actor fails atauthorize(AZ-M58); a new step-up makes the third succeed;grant.createby the Owner still passes step-up withinauth.step_up_max_ageafter a consumption.TestIDT28_ConcurrentOpeningsOneConfirmation: eight goroutines with the same actor after one confirmation run the open harness in parallel: all passauthorize, exactly oneConsumeFreshsucceeds, the others returnstep_up_required, andstep_up_consumed_atwas written once.TestConsumeFresh_Rules: outside a unit of work (internal error), revoked session (401 session_revoked), console session (step_up_required), a step-up that aged out betweenauthorizeand the lock (step_up_required).TestTMT09_I6: over HTTP as the Owner with fresh step-up:createGrantterminalon R2 to Sam and to group Leser give409 i6_violationwithhintandconflicts(AZ-M57); to Robin gives201;setHostLlmCredentialson R1 topersonalwith holder Alex while Sam has a grant gives409 i6_violation; in each refusalgrants,hosts,terminal_sessions,permission_version,event_logand allow audit rows are unchanged, and exactly one deny entry withdeny_reason = i6_violationexists.TestI6_UserEnableRevivesGrant: Sam's status is set todisabledthrough the identity test helper, the grant to Sam on R1 is dormant, R1 switches topersonalwith holder Alex (allowed: no valid grant to another subject);user.enableof Sam then gives409 i6_violationand Sam stays disabled.TestI6_SwitchEndsForeignSessions: the Owner gets the assignmentbothy-s-owneron R1 and R1 reports it; R1 with open sessions of Alex and the Owner; Sam's grant revoked; switch topersonalwith holder Alex:200, the Owner's sessionclosedwithi6_violationin the same commit (samecorrelation_idonhost.updated, thehost.manageentry and theterminal.terminateentry), Alex's session open;access.changedfor the Owner withremovedhost R1.TestI6_HolderChangeEndsForeignSessions: host R4 (active, no assignee,personalwith holder Robin, Robin a second owner, no grants, assignments of Robin and the Owner reported), Robin's session open; the holder changes to the Owner: Robin's sessionclosedwithi6_violation; a variant that fails in phase 3.1 (a test subscriber vetoes) leaves Robin's session open and the declaration unchanged.TestSR10_AssigneeChangeEndsSessions: the Owner gets the assignmentbothy-s-owneron R1 and R1 reports it; sessions of Alex and the Owner on R1;updateHostsets the assignee to Sam: Alex's sessionclosedwithaccess_revokedin the same commit, the Owner's open; clearing the assignee ends Sam's session; with Alex as a second owner, the change leaves Alex's session open.TestSetHostLlmCredentials_HTTP: happy path (200Host,versionraised,ETag,host.updatedvalid againstHostUpdatedEvent, audit details of Design note 10,permission_versionraised); unchanged declaration (200, no new version, no event, auditno_change: true);400 validation_failedwitherrors[].coderequired,not_allowed,unknown_userand for the reason; missingIf-Match(428 precondition_required), different (412 version_conflict); unknown host (404 not_found, same body as an invisible host); Alex as assignee (403 forbiddenwithreasonowner_only); Robin without relation to R1 (404 not_found); the Owner without fresh step-up (401 step_up_required); a personal token of Alex (403 forbiddenwithreasonsubject_kind_not_allowed); aretiredhost (409 conflict).TestSR12_ReportDropsName:RecordSessionUserReportwith causehost.connectfor R1 withoutbothy-s-alex:permission_versionraised,authorize(terminal.open)for Alex givesterminal_no_session_user, Alex's open session on R1closedwithaccess_revokedin the same transaction,host.updatedwritten; the same report again: no transaction, no version; the name reported again: Alex is allowed again; an invalid entry (pattern, length): nothing written.TestPreviewHostGrant: over HTTP as the Owner: after the Owner revokes Sam's grant on R1, a preview of a grant to Sam on R1 givesallowed = true, hintterminal_not_effectivewithprincipal_idSam, one effect for Sam withtarget_kind = host,target_idR1,before_levelandafter_levelnoneandcapabilities_added: [terminal], and the German text of the golden file; with Sam's grant still in place the same preview has the hint and the text but no effect for Sam; grant Alex on R1 has no such hint; after Alex's assignment on R1 ended, the same preview has the hint with the causeno_session_user; grant to group Leser on R1 hasgroup_idandcount1; grant Sam on R2 givesallowed = falsewith the I6 entry and noterminal_not_effectivehint; after each preview the counts ofgrants,event_log,audit_eventsandpermission_versionare unchanged.TestTerminalAuthzAudit_NoContent: every audit row these tests wrote holds no host name, noos_user, no step-up challenge, signature or token text, and no JSONnull.TestAZP1_TerminalAuthzRoutes:setHostLlmCredentialsis registered withhost.manageandrl.admin, the server starts, and no route withterminal.open,terminal.attach,terminal.stream,terminal.read_own,terminal.close,host.pairorhost.connectis enabled by this ticket.
Contract: responses of setHostLlmCredentials, updateHost, createGrant and previewGrant validate against Host, Grant, GrantPreview and Problem in the integration tests; problems carry code and, where named above, reason, hint and conflicts.
Red team focus of the milestone (PLAN 8 "Red team", terminal paragraph: opening without fresh or with consumed step-up, over group grants on a host with personal, by a second owner under I6, on the server host by people who are not owners, on foreign hosts despite a grant, without an own session user, by agent, app and personal tokens, sessions after a revocation) is covered by TestIDT28_*, TestAZ_M56_*, TestAZ_M57_*, TestTMT08_SR10, TestTMT09_I6, TestTMT28a_NoSessionUser, TestSubjectGate_TerminalActions, TestI6_UserEnableRevivesGrant, TestI6_HolderChangeEndsForeignSessions, TestSR10_AssigneeChangeEndsSessions and TestSR12_ReportDropsName; name them in the red team request.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/authz/... ./internal/identity/... ./internal/terminal/... ./internal/kernel/... ./internal/wire/... ./api/...make codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-check,make catalog-checkmake migrations-lint(only with the conditional migration, aftermake migrations-lock)make config-referencethenmake config-doc-checkmake docsmake check
Acceptance criteria¶
-
terminal.open,terminal.attach,terminal.stream,terminal.read_ownandterminal.closeare real requirements and no longer in the deferred golden list;setHostLlmCredentialsis enabled and registered (AZ-P1, AZ-P3). - AZ-M54 to AZ-M61 give the outcomes and reasons of 07 11.1 (AZ-M60 with
terminal.open). - TM-T08, TM-T09 and TM-T28 (a) hold as decisions of
authorizeand, for the I6 refusals, over HTTP with409 i6_violation; the denials map to403 terminal_deniedwith thereasonof 24 13. - An assignment whose name the host no longer reports gives
terminal_no_session_user, and a change of the report raises the rights version; an unchanged report raises nothing. - ID-T28 without the console part:
password_totp, a step-up older thanterminal.step_up_max_ageand a second opening with one confirmation givestep_up_required; concurrent openings with one confirmation give exactly one consumption. - Switching to
personalwith grants to others gives409 i6_violationand changes nothing; switching topersonalor changing the holder ends the sessions of other people in the same transaction withi6_violation; changing the assignee ends the old assignee's sessions in the same transaction withaccess_revoked. - The preview of a grant on a host to a person who is not the assignee says "does not take effect" (
terminal_not_effective, golden texts in German and English); an I6 violation givesallowed = false. - Agents, apps and personal tokens get
403 forbiddenwithreasonsubject_kind_not_allowedon every terminal action. - Audit, events and problems of this ticket hold no content, names, tokens or
null. - 07 4.2, SR10 and 9.3, 08 6.3, 24 7.2, 7.3 and 13 and
openapi.yamlmatch the implementation; rulings recorded. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
authz,identityandterminalupdated (terminal entries, step-upfreshandConsumeFresh, the guards, the session users in the rights state, I6 inVerifyInvariants,setHostLlmCredentials,RecordSessionUserReportandApplySessionUserReportfor M2-41 and M2-43, the extension point of the guard for M2-44 and M2-46). - Rulings of note 14 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 07, 08, 24 andopenapi.yamlof the Scope made in the same change. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.