feat: select Windows execution contexts
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
// Package windows contains Windows-supervisor policy that is safe to unit test
|
||||||
|
// without loading Win32. Narrow build-tagged adapters obtain and verify the
|
||||||
|
// real token/session facts before they reach this selector.
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecutionContext is the only context vocabulary exposed by the Windows v1
|
||||||
|
// policy. The caller expresses merely Elevated; all fallback ordering remains
|
||||||
|
// local to the client daemon.
|
||||||
|
type ExecutionContext string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextActiveUser ExecutionContext = "ACTIVE_USER"
|
||||||
|
ContextActiveUserElevated ExecutionContext = "ACTIVE_USER_ELEVATED"
|
||||||
|
ContextActiveSystem ExecutionContext = "ACTIVE_SYSTEM"
|
||||||
|
ContextLocalService ExecutionContext = "LOCAL_SERVICE"
|
||||||
|
ContextLocalSystem ExecutionContext = "LOCAL_SYSTEM"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttemptReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReasonSelected AttemptReason = "SELECTED"
|
||||||
|
ReasonNoUsableActiveSession AttemptReason = "NO_USABLE_ACTIVE_SESSION"
|
||||||
|
ReasonAmbiguousActiveSessions AttemptReason = "AMBIGUOUS_ACTIVE_SESSIONS"
|
||||||
|
ReasonStandardToken AttemptReason = "STANDARD_OR_FILTERED_TOKEN"
|
||||||
|
ReasonRestrictedToken AttemptReason = "RESTRICTED_MEDIUM_TOKEN"
|
||||||
|
ReasonRestrictedTokenUnavailable AttemptReason = "RESTRICTED_TOKEN_UNAVAILABLE"
|
||||||
|
ReasonElevationUnavailable AttemptReason = "ELEVATION_UNAVAILABLE"
|
||||||
|
ReasonApprovalPolicy AttemptReason = "APPROVAL_POLICY"
|
||||||
|
ReasonActiveSystemUnavailable AttemptReason = "ACTIVE_SYSTEM_UNAVAILABLE"
|
||||||
|
ReasonLocalServiceUnavailable AttemptReason = "LOCAL_SERVICE_UNAVAILABLE"
|
||||||
|
ReasonLocalSystemUnavailable AttemptReason = "LOCAL_SYSTEM_UNAVAILABLE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenFacts are verified observations, not token handles. The native adapter
|
||||||
|
// must set these only after it has checked SID, session, type, elevation, and
|
||||||
|
// integrity properties.
|
||||||
|
type TokenFacts struct {
|
||||||
|
Usable bool
|
||||||
|
StandardOrFiltered bool
|
||||||
|
FullAdministrator bool
|
||||||
|
LinkedFullAvailable bool
|
||||||
|
RestrictedMediumAllowed bool
|
||||||
|
ApprovalPolicyRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionCandidate represents an active WTS session after native enumeration.
|
||||||
|
// It deliberately contains no handles or credentials.
|
||||||
|
type SessionCandidate struct {
|
||||||
|
SessionID uint32
|
||||||
|
Console bool
|
||||||
|
UserSID string
|
||||||
|
LogonSID string
|
||||||
|
Token TokenFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionInput struct {
|
||||||
|
Elevated bool
|
||||||
|
ActiveSessions []SessionCandidate
|
||||||
|
ActiveSystemAvailable bool
|
||||||
|
LocalServiceAvailable bool
|
||||||
|
LocalSystemAvailable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attempt struct {
|
||||||
|
Context ExecutionContext
|
||||||
|
Reason AttemptReason
|
||||||
|
Success bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Identity struct {
|
||||||
|
Context ExecutionContext
|
||||||
|
SessionID *uint32
|
||||||
|
UserSID string
|
||||||
|
LogonSID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection struct {
|
||||||
|
Elevated bool
|
||||||
|
Attempts []Attempt
|
||||||
|
Effective *Identity
|
||||||
|
NoActiveReason AttemptReason
|
||||||
|
Error *domain.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select applies the v1 hierarchy. It never chooses an arbitrary active
|
||||||
|
// session and never substitutes a service identity for a failed normal active
|
||||||
|
// user selection.
|
||||||
|
func Select(input SelectionInput) Selection {
|
||||||
|
selected, absentReason := chooseActiveSession(input.ActiveSessions)
|
||||||
|
result := Selection{Elevated: input.Elevated, NoActiveReason: absentReason}
|
||||||
|
if selected != nil {
|
||||||
|
if !input.Elevated {
|
||||||
|
return selectActiveNormal(result, *selected)
|
||||||
|
}
|
||||||
|
return selectActiveElevated(result, *selected, input)
|
||||||
|
}
|
||||||
|
if input.Elevated {
|
||||||
|
return selectNoUserElevated(result, input)
|
||||||
|
}
|
||||||
|
return selectNoUserNormal(result, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseActiveSession(candidates []SessionCandidate) (*SessionCandidate, AttemptReason) {
|
||||||
|
var usable []SessionCandidate
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.Token.Usable && candidate.UserSID != "" && candidate.LogonSID != "" {
|
||||||
|
if candidate.Console {
|
||||||
|
selected := candidate
|
||||||
|
return &selected, ""
|
||||||
|
}
|
||||||
|
usable = append(usable, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usable) == 1 {
|
||||||
|
return &usable[0], ""
|
||||||
|
}
|
||||||
|
if len(usable) > 1 {
|
||||||
|
return nil, ReasonAmbiguousActiveSessions
|
||||||
|
}
|
||||||
|
return nil, ReasonNoUsableActiveSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectActiveNormal(result Selection, candidate SessionCandidate) Selection {
|
||||||
|
if candidate.Token.StandardOrFiltered {
|
||||||
|
return success(result, ContextActiveUser, ReasonStandardToken, candidate)
|
||||||
|
}
|
||||||
|
if candidate.Token.FullAdministrator && candidate.Token.RestrictedMediumAllowed {
|
||||||
|
return success(result, ContextActiveUser, ReasonRestrictedToken, candidate)
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextActiveUser, Reason: ReasonRestrictedTokenUnavailable})
|
||||||
|
result.Error = domain.NewExecutionContextUnavailable("a non-elevated active-user token could not be prepared", "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectActiveElevated(result Selection, candidate SessionCandidate, input SelectionInput) Selection {
|
||||||
|
if candidate.Token.ApprovalPolicyRequired {
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextActiveUserElevated, Reason: ReasonApprovalPolicy})
|
||||||
|
} else if candidate.Token.FullAdministrator || candidate.Token.LinkedFullAvailable {
|
||||||
|
return success(result, ContextActiveUserElevated, ReasonSelected, candidate)
|
||||||
|
} else {
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextActiveUserElevated, Reason: ReasonElevationUnavailable})
|
||||||
|
}
|
||||||
|
if input.ActiveSystemAvailable {
|
||||||
|
return success(result, ContextActiveSystem, ReasonSelected, candidate)
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextActiveSystem, Reason: ReasonActiveSystemUnavailable})
|
||||||
|
if input.LocalSystemAvailable {
|
||||||
|
return success(result, ContextLocalSystem, ReasonSelected, SessionCandidate{})
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextLocalSystem, Reason: ReasonLocalSystemUnavailable})
|
||||||
|
result.Error = domain.NewElevationUnavailable("no elevated Windows execution context could be prepared", "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNoUserNormal(result Selection, input SelectionInput) Selection {
|
||||||
|
if input.LocalServiceAvailable {
|
||||||
|
return success(result, ContextLocalService, ReasonSelected, SessionCandidate{})
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextLocalService, Reason: ReasonLocalServiceUnavailable})
|
||||||
|
result.Error = domain.NewExecutionContextUnavailable("LocalService execution context could not be prepared", "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNoUserElevated(result Selection, input SelectionInput) Selection {
|
||||||
|
if input.LocalSystemAvailable {
|
||||||
|
return success(result, ContextLocalSystem, ReasonSelected, SessionCandidate{})
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: ContextLocalSystem, Reason: ReasonLocalSystemUnavailable})
|
||||||
|
result.Error = domain.NewElevationUnavailable("LocalSystem execution context could not be prepared", "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func success(result Selection, context ExecutionContext, reason AttemptReason, candidate SessionCandidate) Selection {
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{Context: context, Reason: reason, Success: true})
|
||||||
|
identity := &Identity{Context: context}
|
||||||
|
if context == ContextActiveUser || context == ContextActiveUserElevated || context == ContextActiveSystem {
|
||||||
|
identity.SessionID = &candidate.SessionID
|
||||||
|
identity.UserSID = candidate.UserSID
|
||||||
|
identity.LogonSID = candidate.LogonSID
|
||||||
|
}
|
||||||
|
result.Effective = identity
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (selection Selection) Validate() error {
|
||||||
|
if selection.Effective != nil && selection.Error != nil {
|
||||||
|
return fmt.Errorf("effective context and error cannot coexist")
|
||||||
|
}
|
||||||
|
if selection.Effective == nil && selection.Error == nil {
|
||||||
|
return fmt.Errorf("selection has neither context nor error")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectExecutionContext_HP_WINCTX_02(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
standard := active(TokenFacts{Usable: true, StandardOrFiltered: true})
|
||||||
|
full := active(TokenFacts{Usable: true, FullAdministrator: true})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input SelectionInput
|
||||||
|
want ExecutionContext
|
||||||
|
attempts []ExecutionContext
|
||||||
|
}{
|
||||||
|
{"active standard normal", SelectionInput{ActiveSessions: []SessionCandidate{standard}}, ContextActiveUser, []ExecutionContext{ContextActiveUser}},
|
||||||
|
{"active full normal restricted", SelectionInput{ActiveSessions: []SessionCandidate{active(TokenFacts{Usable: true, FullAdministrator: true, RestrictedMediumAllowed: true})}}, ContextActiveUser, []ExecutionContext{ContextActiveUser}},
|
||||||
|
{"active linked admin elevated", SelectionInput{Elevated: true, ActiveSessions: []SessionCandidate{active(TokenFacts{Usable: true, LinkedFullAvailable: true})}}, ContextActiveUserElevated, []ExecutionContext{ContextActiveUserElevated}},
|
||||||
|
{"active full admin elevated", SelectionInput{Elevated: true, ActiveSessions: []SessionCandidate{full}}, ContextActiveUserElevated, []ExecutionContext{ContextActiveUserElevated}},
|
||||||
|
{"active elevation fallback", SelectionInput{Elevated: true, ActiveSessions: []SessionCandidate{standard}, ActiveSystemAvailable: true}, ContextActiveSystem, []ExecutionContext{ContextActiveUserElevated, ContextActiveSystem}},
|
||||||
|
{"active local system final fallback", SelectionInput{Elevated: true, ActiveSessions: []SessionCandidate{standard}, LocalSystemAvailable: true}, ContextLocalSystem, []ExecutionContext{ContextActiveUserElevated, ContextActiveSystem, ContextLocalSystem}},
|
||||||
|
{"no user normal", SelectionInput{LocalServiceAvailable: true}, ContextLocalService, []ExecutionContext{ContextLocalService}},
|
||||||
|
{"no user elevated", SelectionInput{Elevated: true, LocalSystemAvailable: true}, ContextLocalSystem, []ExecutionContext{ContextLocalSystem}},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := Select(test.input)
|
||||||
|
if err := got.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Effective == nil || got.Effective.Context != test.want {
|
||||||
|
t.Fatalf("effective = %+v, want %s", got.Effective, test.want)
|
||||||
|
}
|
||||||
|
if len(got.Attempts) != len(test.attempts) {
|
||||||
|
t.Fatalf("attempt count = %d, want %d", len(got.Attempts), len(test.attempts))
|
||||||
|
}
|
||||||
|
for index, want := range test.attempts {
|
||||||
|
if got.Attempts[index].Context != want {
|
||||||
|
t.Fatalf("attempt %d = %s, want %s", index, got.Attempts[index].Context, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got.Effective.Context == ContextLocalSystem || got.Effective.Context == ContextLocalService {
|
||||||
|
if got.Effective.SessionID != nil || got.Effective.UserSID != "" || got.Effective.LogonSID != "" {
|
||||||
|
t.Fatalf("Session 0 identity leaked active-session fields: %+v", got.Effective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = rvboxv1.ControlError_CODE_ELEVATION_UNAVAILABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectExecutionContextBoundaries_BH_WINCTX_02(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
standard := active(TokenFacts{Usable: true, StandardOrFiltered: true})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input SelectionInput
|
||||||
|
wantCode rvboxv1.ControlError_Code
|
||||||
|
wantReason AttemptReason
|
||||||
|
wantTry []ExecutionContext
|
||||||
|
}{
|
||||||
|
{"normal active cannot downgrade full", SelectionInput{ActiveSessions: []SessionCandidate{active(TokenFacts{Usable: true, FullAdministrator: true})}, LocalServiceAvailable: true}, rvboxv1.ControlError_CODE_EXECUTION_CONTEXT_UNAVAILABLE, "", []ExecutionContext{ContextActiveUser}},
|
||||||
|
{"approval falls back", SelectionInput{Elevated: true, ActiveSessions: []SessionCandidate{active(TokenFacts{Usable: true, ApprovalPolicyRequired: true})}, ActiveSystemAvailable: false, LocalSystemAvailable: true}, 0, "", []ExecutionContext{ContextActiveUserElevated, ContextActiveSystem, ContextLocalSystem}},
|
||||||
|
{"ambiguous sessions use no user row", SelectionInput{Elevated: false, ActiveSessions: ambiguousSessions(standard.Token), LocalServiceAvailable: true}, 0, ReasonAmbiguousActiveSessions, []ExecutionContext{ContextLocalService}},
|
||||||
|
{"no user service unavailable", SelectionInput{}, rvboxv1.ControlError_CODE_EXECUTION_CONTEXT_UNAVAILABLE, ReasonNoUsableActiveSession, []ExecutionContext{ContextLocalService}},
|
||||||
|
{"no user elevated unavailable", SelectionInput{Elevated: true}, rvboxv1.ControlError_CODE_ELEVATION_UNAVAILABLE, ReasonNoUsableActiveSession, []ExecutionContext{ContextLocalSystem}},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := Select(test.input)
|
||||||
|
if err := got.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.wantCode != 0 {
|
||||||
|
if got.Error == nil || got.Error.Code != test.wantCode {
|
||||||
|
t.Fatalf("error = %+v, want %s", got.Error, test.wantCode)
|
||||||
|
}
|
||||||
|
} else if got.Effective == nil {
|
||||||
|
t.Fatalf("selection failed: %+v", got.Error)
|
||||||
|
}
|
||||||
|
if got.NoActiveReason != test.wantReason {
|
||||||
|
t.Fatalf("no-active reason = %q, want %q", got.NoActiveReason, test.wantReason)
|
||||||
|
}
|
||||||
|
if len(got.Attempts) != len(test.wantTry) {
|
||||||
|
t.Fatalf("attempts = %+v", got.Attempts)
|
||||||
|
}
|
||||||
|
for index, want := range test.wantTry {
|
||||||
|
if got.Attempts[index].Context != want {
|
||||||
|
t.Fatalf("attempt %d = %s, want %s", index, got.Attempts[index].Context, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func active(token TokenFacts) SessionCandidate {
|
||||||
|
return SessionCandidate{SessionID: 1, Console: true, UserSID: "S-1-5-21-1", LogonSID: "S-1-5-5-1-2", Token: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ambiguousSessions(token TokenFacts) []SessionCandidate {
|
||||||
|
first := active(token)
|
||||||
|
first.Console = false
|
||||||
|
second := active(token)
|
||||||
|
second.Console = false
|
||||||
|
second.SessionID = 2
|
||||||
|
second.UserSID = "S-1-5-21-2"
|
||||||
|
second.LogonSID = "S-1-5-5-2-3"
|
||||||
|
return []SessionCandidate{first, second}
|
||||||
|
}
|
||||||
+10
-1
@@ -104,7 +104,16 @@ tests = ["internal/domain/sequence_test.go:TestEventSequenceAndDuplicateEquivale
|
|||||||
id = "HP-WINCTX-01"
|
id = "HP-WINCTX-01"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01"]
|
tests = [
|
||||||
|
"internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01",
|
||||||
|
"internal/client/supervisor/windows/selection_test.go:TestSelectExecutionContext_HP_WINCTX_02",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-WINCTX-02"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/client/supervisor/windows/selection_test.go:TestSelectExecutionContextBoundaries_BH_WINCTX_02"]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "BH-SES-01"
|
id = "BH-SES-01"
|
||||||
|
|||||||
Reference in New Issue
Block a user