Implement authorization import and revocation checks (T-503)

This commit is contained in:
ila
2026-07-20 09:07:30 +08:00
parent b4453130de
commit 76f6108496
36 changed files with 2197 additions and 68 deletions
+418
View File
@@ -0,0 +1,418 @@
package application
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"time"
"softbox.local/core/licensing"
)
var (
ErrAuthorizationConfig = errors.New("authorization configuration is invalid")
ErrAuthorizationSourceUnconfigured = errors.New("authorization source is unconfigured")
ErrAuthorizationUnavailable = errors.New("authorization is unavailable")
ErrAuthorizationImport = errors.New("authorization import failed")
ErrAuthorizationEventPayload = errors.New("authorization event payload is invalid")
)
var authorizationProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// AuthorizationState is the sanitized state shown to adapters and callers.
type AuthorizationState string
const (
AuthorizationStateReady AuthorizationState = "ready"
AuthorizationStateGrace AuthorizationState = "grace"
AuthorizationStateNoLicense AuthorizationState = "no_license"
AuthorizationStateRevoked AuthorizationState = "revoked"
AuthorizationStateUnavailable AuthorizationState = "unavailable"
AuthorizationStateUnconfigured AuthorizationState = "unconfigured"
AuthorizationStateImportFailed AuthorizationState = "import_failed"
)
// LicenseKind presents only the signed perpetual flag; it does not invent a
// trial expiration which License v1 does not carry.
type LicenseKind string
const (
LicenseKindPerpetual LicenseKind = "perpetual"
LicenseKindNonPerpetual LicenseKind = "non_perpetual"
)
// AuthorizedProduct contains no license ID, source path, signature or source
// document. The product ID is the same signed package identity used at launch.
type AuthorizedProduct struct {
ProductID string
Kind LicenseKind
RebindPolicy string
}
// AuthorizationSnapshot is an IO-free UI/launcher authorization view.
type AuthorizationSnapshot struct {
State AuthorizationState
MachineHash string
Products []AuthorizedProduct
}
// AuthorizationEvent is the typed LicenseChanged payload accepted by adapters.
type AuthorizationEvent struct {
Type EventType
Snapshot AuthorizationSnapshot
}
// AuthorizationStore is the narrow persistence boundary required by the
// authorization application service.
type AuthorizationStore interface {
Import([]byte, licensing.Verifier, string) (licensing.License, bool, error)
List(licensing.Verifier, string) ([]licensing.License, error)
StoreRevocations([]byte, licensing.RevocationVerifier) (licensing.RevocationList, error)
LoadRevocations(licensing.RevocationVerifier) (licensing.RevocationList, bool, error)
}
// LicenseImportSource reads one user-selected document outside Gio Layout.
// Platform file dialogs and file I/O belong to its composition implementation.
type LicenseImportSource interface {
ReadLicense(context.Context) ([]byte, error)
}
// AuthorizationServiceConfig makes every trust and local dependency explicit.
type AuthorizationServiceConfig struct {
Store AuthorizationStore
LicenseVerifier licensing.Verifier
RevocationVerifier licensing.RevocationVerifier
MachineHash string
Clock func() time.Time
}
// AuthorizationService revalidates cached licenses/revocations for every
// snapshot and authorization decision. It is core-only and has no Gio or
// platform imports.
type AuthorizationService struct {
store AuthorizationStore
licenseVerifier licensing.Verifier
revocationVerifier licensing.RevocationVerifier
machineHash string
clock func() time.Time
}
// NewAuthorizationService creates the configured offline authorization use
// case. Invalid verifier values fail during a use rather than falling back.
func NewAuthorizationService(config AuthorizationServiceConfig) (*AuthorizationService, error) {
if config.Store == nil || !machineHashForAuthorization(config.MachineHash) {
return nil, ErrAuthorizationConfig
}
if config.Clock == nil {
config.Clock = time.Now
}
return &AuthorizationService{
store: config.Store,
licenseVerifier: config.LicenseVerifier,
revocationVerifier: config.RevocationVerifier,
machineHash: config.MachineHash,
clock: config.Clock,
}, nil
}
// Snapshot loads a detached, sanitized authorization view.
func (service *AuthorizationService) Snapshot(ctx context.Context) (AuthorizationSnapshot, error) {
if service == nil || service.store == nil || service.clock == nil {
return AuthorizationSnapshot{}, ErrAuthorizationConfig
}
if err := ctx.Err(); err != nil {
return AuthorizationSnapshot{}, err
}
licenses, err := service.store.List(service.licenseVerifier, service.machineHash)
if err != nil {
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: cache", ErrAuthorizationUnavailable)
}
list, found, err := service.store.LoadRevocations(service.revocationVerifier)
if err != nil || !found {
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w: revocation cache", ErrAuthorizationUnavailable)
}
snapshot := authorizationSnapshotFromVerified(licenses, list, service.machineHash, service.clock())
if snapshot.State == AuthorizationStateUnavailable {
return snapshot, ErrAuthorizationUnavailable
}
return snapshot, nil
}
// Import validates and persists one document before returning the refreshed
// authorization view. No unverified document is retained.
func (service *AuthorizationService) Import(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
if service == nil || service.store == nil {
return AuthorizationSnapshot{}, ErrAuthorizationConfig
}
if err := ctx.Err(); err != nil {
return AuthorizationSnapshot{}, err
}
if _, _, err := service.store.Import(document, service.licenseVerifier, service.machineHash); err != nil {
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationImport)
}
return service.Snapshot(ctx)
}
// UpdateRevocations validates and persists a signed list then refreshes state.
func (service *AuthorizationService) UpdateRevocations(ctx context.Context, document []byte) (AuthorizationSnapshot, error) {
if service == nil || service.store == nil {
return AuthorizationSnapshot{}, ErrAuthorizationConfig
}
if err := ctx.Err(); err != nil {
return AuthorizationSnapshot{}, err
}
if _, err := service.store.StoreRevocations(document, service.revocationVerifier); err != nil {
return unavailableAuthorizationSnapshot(service.machineHash), fmt.Errorf("%w", ErrAuthorizationUnavailable)
}
return service.Snapshot(ctx)
}
// IsAuthorized implements the launch AuthorizationChecker contract for a
// package product ID. Missing/stale revocations are unavailable, never allow.
func (service *AuthorizationService) IsAuthorized(productID string) (bool, error) {
if !authorizationProductIDPattern.MatchString(productID) {
return false, ErrAuthorizationUnavailable
}
snapshot, err := service.Snapshot(context.Background())
if err != nil {
return false, err
}
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace {
return false, nil
}
for _, product := range snapshot.Products {
if product.ProductID == productID {
return true, nil
}
}
return false, nil
}
func authorizationSnapshotFromVerified(
licenses []licensing.License,
list licensing.RevocationList,
machineHash string,
now time.Time,
) AuthorizationSnapshot {
if now.Before(list.GeneratedAt) || now.After(list.ExpiresAt.Add(licensing.RevocationGrace)) {
return unavailableAuthorizationSnapshot(machineHash)
}
products := make(map[string]AuthorizedProduct)
hasGrace := false
hasRevoked := false
for _, license := range licenses {
switch list.StateFor(license.LicenseID, now) {
case licensing.RevocationStateCurrent:
addAuthorizedProducts(products, license)
case licensing.RevocationStateGrace:
hasGrace = true
addAuthorizedProducts(products, license)
case licensing.RevocationStateRevoked:
hasRevoked = true
}
}
if len(products) == 0 {
state := AuthorizationStateNoLicense
if hasRevoked {
state = AuthorizationStateRevoked
}
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: []AuthorizedProduct{}}
}
ordered := make([]AuthorizedProduct, 0, len(products))
for _, product := range products {
ordered = append(ordered, product)
}
sort.Slice(ordered, func(left, right int) bool {
return ordered[left].ProductID < ordered[right].ProductID
})
state := AuthorizationStateReady
if hasGrace {
state = AuthorizationStateGrace
}
return AuthorizationSnapshot{State: state, MachineHash: machineHash, Products: ordered}
}
func addAuthorizedProducts(products map[string]AuthorizedProduct, license licensing.License) {
kind := LicenseKindNonPerpetual
if license.Perpetual {
kind = LicenseKindPerpetual
}
for _, productID := range license.Products {
candidate := AuthorizedProduct{ProductID: productID, Kind: kind, RebindPolicy: license.RebindPolicy}
if existing, exists := products[productID]; !exists ||
(existing.Kind == LicenseKindNonPerpetual && candidate.Kind == LicenseKindPerpetual) {
products[productID] = candidate
}
}
}
func unavailableAuthorizationSnapshot(machineHash string) AuthorizationSnapshot {
return AuthorizationSnapshot{
State: AuthorizationStateUnavailable,
MachineHash: machineHash,
Products: []AuthorizedProduct{},
}
}
func machineHashForAuthorization(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f') {
return false
}
}
return true
}
// AuthorizationSnapshotLoader supplies one background-loaded authorization
// state. It permits the explicit unconfigured default without test-key fallback.
type AuthorizationSnapshotLoader interface {
LoadAuthorizationSnapshot(context.Context) (AuthorizationSnapshot, error)
}
// LoadAuthorizationSnapshot adapts AuthorizationService for bootstrap use.
func (service *AuthorizationService) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
return service.Snapshot(ctx)
}
// UnconfiguredAuthorizationLoader is the safe default until release
// composition supplies a trusted key, machine hash and revocation source.
type UnconfiguredAuthorizationLoader struct{}
func (UnconfiguredAuthorizationLoader) LoadAuthorizationSnapshot(ctx context.Context) (AuthorizationSnapshot, error) {
if err := ctx.Err(); err != nil {
return AuthorizationSnapshot{}, err
}
return AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}, ErrAuthorizationSourceUnconfigured
}
// AuthorizationBootstrap publishes exactly one sanitized startup state.
type AuthorizationBootstrap struct {
loader AuthorizationSnapshotLoader
publisher EventPublisher
}
func NewAuthorizationBootstrap(loader AuthorizationSnapshotLoader, publisher EventPublisher) *AuthorizationBootstrap {
return &AuthorizationBootstrap{loader: loader, publisher: publisher}
}
func (bootstrap *AuthorizationBootstrap) Run(ctx context.Context) error {
if bootstrap == nil || bootstrap.loader == nil || bootstrap.publisher == nil {
return ErrAuthorizationConfig
}
snapshot, err := bootstrap.loader.LoadAuthorizationSnapshot(ctx)
if err != nil {
if errors.Is(err, ErrAuthorizationSourceUnconfigured) {
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnconfigured, Products: []AuthorizedProduct{}}
} else if !validAuthorizationSnapshot(snapshot) {
snapshot = AuthorizationSnapshot{State: AuthorizationStateUnavailable, Products: []AuthorizedProduct{}}
}
}
publishErr := bootstrap.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
if publishErr != nil {
return errors.Join(err, publishErr)
}
return err
}
// LicenseImport publishes a refreshed authorization snapshot from one
// background-only source read.
type LicenseImport struct {
service *AuthorizationService
source LicenseImportSource
publisher EventPublisher
}
func NewLicenseImport(service *AuthorizationService, source LicenseImportSource, publisher EventPublisher) *LicenseImport {
return &LicenseImport{service: service, source: source, publisher: publisher}
}
func (useCase *LicenseImport) Run(ctx context.Context) error {
if useCase == nil || useCase.service == nil || useCase.source == nil || useCase.publisher == nil {
return ErrAuthorizationConfig
}
document, err := useCase.source.ReadLicense(ctx)
if err == nil {
_, err = useCase.service.Import(ctx, document)
}
snapshot, snapshotErr := useCase.service.Snapshot(ctx)
if snapshotErr != nil || err != nil {
snapshot = AuthorizationSnapshot{State: AuthorizationStateImportFailed, MachineHash: useCase.service.machineHash, Products: []AuthorizedProduct{}}
}
publishErr := useCase.publisher.Publish(ctx, NewAuthorizationEvent(snapshot))
return errors.Join(err, snapshotErr, publishErr)
}
// NewAuthorizationEvent deep-copies a validated snapshot into LicenseChanged.
func NewAuthorizationEvent(snapshot AuthorizationSnapshot) Event {
return Event{Type: EventLicenseChanged, Payload: AuthorizationEvent{
Type: EventLicenseChanged,
Snapshot: cloneAuthorizationSnapshot(snapshot),
}}
}
// ParseAuthorizationEvent validates and deep-copies a LicenseChanged payload.
func ParseAuthorizationEvent(event Event) (AuthorizationEvent, bool, error) {
if event.Type != EventLicenseChanged {
return AuthorizationEvent{}, false, nil
}
payload, ok := event.Payload.(AuthorizationEvent)
if !ok || payload.Type != EventLicenseChanged || event.RequestID != "" || event.AppID != "" ||
!validAuthorizationSnapshot(payload.Snapshot) {
return AuthorizationEvent{}, true, ErrAuthorizationEventPayload
}
payload.Snapshot = cloneAuthorizationSnapshot(payload.Snapshot)
return payload, true, nil
}
func cloneAuthorizationSnapshot(snapshot AuthorizationSnapshot) AuthorizationSnapshot {
products := make([]AuthorizedProduct, len(snapshot.Products))
copy(products, snapshot.Products)
snapshot.Products = products
return snapshot
}
func validAuthorizationSnapshot(snapshot AuthorizationSnapshot) bool {
if !snapshot.State.valid() {
return false
}
if snapshot.State == AuthorizationStateUnconfigured && snapshot.MachineHash != "" {
return false
}
if snapshot.State != AuthorizationStateUnconfigured && !machineHashForAuthorization(snapshot.MachineHash) {
return false
}
if snapshot.Products == nil {
return false
}
seen := make(map[string]struct{}, len(snapshot.Products))
for _, product := range snapshot.Products {
if !authorizationProductIDPattern.MatchString(product.ProductID) ||
(product.Kind != LicenseKindPerpetual && product.Kind != LicenseKindNonPerpetual) ||
product.RebindPolicy == "" {
return false
}
if _, exists := seen[product.ProductID]; exists {
return false
}
seen[product.ProductID] = struct{}{}
}
if (snapshot.State == AuthorizationStateReady || snapshot.State == AuthorizationStateGrace) && len(snapshot.Products) == 0 {
return false
}
if snapshot.State != AuthorizationStateReady && snapshot.State != AuthorizationStateGrace && len(snapshot.Products) != 0 {
return false
}
return true
}
func (state AuthorizationState) valid() bool {
return state == AuthorizationStateReady || state == AuthorizationStateGrace ||
state == AuthorizationStateNoLicense || state == AuthorizationStateRevoked ||
state == AuthorizationStateUnavailable || state == AuthorizationStateUnconfigured ||
state == AuthorizationStateImportFailed
}
+162
View File
@@ -0,0 +1,162 @@
package application
import (
"context"
"errors"
"testing"
"time"
"softbox.local/core/licensing"
)
const applicationTestMachineHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func TestAuthorizationServiceUsesCurrentGraceAndRevocationStates(t *testing.T) {
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
store := &authorizationStoreFake{
licenses: []licensing.License{{
LicenseID: "lic-active", Products: []string{"product-active"}, Perpetual: true, RebindPolicy: "support-only",
}, {
LicenseID: "lic-revoked", Products: []string{"product-revoked"}, RebindPolicy: "support-only",
}},
revocations: licensing.RevocationList{
GeneratedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(time.Hour), RevokedLicenseIDs: []string{"lic-revoked"},
},
found: true,
}
service := newAuthorizationServiceForTest(t, store, now)
snapshot, err := service.Snapshot(context.Background())
if err != nil || snapshot.State != AuthorizationStateReady || len(snapshot.Products) != 1 || snapshot.Products[0].ProductID != "product-active" {
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
}
if authorized, err := service.IsAuthorized("product-active"); err != nil || !authorized {
t.Fatalf("IsAuthorized(active) = (%t, %v)", authorized, err)
}
if authorized, err := service.IsAuthorized("product-revoked"); err != nil || authorized {
t.Fatalf("IsAuthorized(revoked) = (%t, %v)", authorized, err)
}
store.revocations.ExpiresAt = now.Add(-time.Hour)
snapshot, err = service.Snapshot(context.Background())
if err != nil || snapshot.State != AuthorizationStateGrace {
t.Fatalf("Snapshot(grace) = (%#v, %v)", snapshot, err)
}
store.revocations.ExpiresAt = now.Add(-licensing.RevocationGrace - time.Second)
snapshot, err = service.Snapshot(context.Background())
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
t.Fatalf("Snapshot(stale) = (%#v, %v)", snapshot, err)
}
}
func TestAuthorizationServiceFailsClosedWithoutRevocations(t *testing.T) {
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
store := &authorizationStoreFake{licenses: []licensing.License{{
LicenseID: "lic-active", Products: []string{"product-active"}, RebindPolicy: "support-only",
}}}
service := newAuthorizationServiceForTest(t, store, now)
snapshot, err := service.Snapshot(context.Background())
if !errors.Is(err, ErrAuthorizationUnavailable) || snapshot.State != AuthorizationStateUnavailable {
t.Fatalf("Snapshot() = (%#v, %v)", snapshot, err)
}
if authorized, err := service.IsAuthorized("product-active"); authorized || !errors.Is(err, ErrAuthorizationUnavailable) {
t.Fatalf("IsAuthorized() = (%t, %v)", authorized, err)
}
}
func TestAuthorizationEventsAndBootstrapAreSanitized(t *testing.T) {
snapshot := AuthorizationSnapshot{
State: AuthorizationStateReady, MachineHash: applicationTestMachineHash,
Products: []AuthorizedProduct{{ProductID: "product-test", Kind: LicenseKindPerpetual, RebindPolicy: "support-only"}},
}
event := NewAuthorizationEvent(snapshot)
snapshot.Products[0].ProductID = "mutated"
parsed, handled, err := ParseAuthorizationEvent(event)
if err != nil || !handled || parsed.Snapshot.Products[0].ProductID != "product-test" {
t.Fatalf("ParseAuthorizationEvent() = (%#v, %t, %v)", parsed, handled, err)
}
if _, _, err := ParseAuthorizationEvent(Event{Type: EventLicenseChanged, Payload: "raw license"}); !errors.Is(err, ErrAuthorizationEventPayload) {
t.Fatalf("ParseAuthorizationEvent(bad) error = %v", err)
}
runtime := NewRuntime(1)
err = NewAuthorizationBootstrap(UnconfiguredAuthorizationLoader{}, runtime).Run(context.Background())
if !errors.Is(err, ErrAuthorizationSourceUnconfigured) {
t.Fatalf("Bootstrap.Run() error = %v", err)
}
event = <-runtime.Events()
payload, handled, err := ParseAuthorizationEvent(event)
if err != nil || !handled || payload.Snapshot.State != AuthorizationStateUnconfigured {
t.Fatalf("unconfigured event = (%#v, %t, %v)", payload, handled, err)
}
}
func TestLicenseImportPublishesSanitizedFailure(t *testing.T) {
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
service := newAuthorizationServiceForTest(t, &authorizationStoreFake{}, now)
runtime := NewRuntime(1)
err := NewLicenseImport(service, licenseSourceFake{document: []byte(`{"secret":"never publish"}`)}, runtime).Run(context.Background())
if err == nil {
t.Fatal("Run() unexpectedly succeeded")
}
payload, handled, parseErr := ParseAuthorizationEvent(<-runtime.Events())
if parseErr != nil || !handled || payload.Snapshot.State != AuthorizationStateImportFailed ||
payload.Snapshot.MachineHash != applicationTestMachineHash || len(payload.Snapshot.Products) != 0 {
t.Fatalf("import event = (%#v, %t, %v)", payload, handled, parseErr)
}
}
func newAuthorizationServiceForTest(t *testing.T, store *authorizationStoreFake, now time.Time) *AuthorizationService {
t.Helper()
service, err := NewAuthorizationService(AuthorizationServiceConfig{
Store: store, MachineHash: applicationTestMachineHash, Clock: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
return service
}
type authorizationStoreFake struct {
licenses []licensing.License
revocations licensing.RevocationList
found bool
listErr error
revokedErr error
}
type licenseSourceFake struct {
document []byte
err error
}
func (source licenseSourceFake) ReadLicense(context.Context) ([]byte, error) {
return append([]byte(nil), source.document...), source.err
}
func (store *authorizationStoreFake) Import(_ []byte, _ licensing.Verifier, _ string) (licensing.License, bool, error) {
return licensing.License{}, false, errors.New("not used")
}
func (store *authorizationStoreFake) List(_ licensing.Verifier, _ string) ([]licensing.License, error) {
if store.listErr != nil {
return nil, store.listErr
}
licenses := append([]licensing.License(nil), store.licenses...)
for index := range licenses {
licenses[index].Products = append([]string(nil), licenses[index].Products...)
}
return licenses, nil
}
func (store *authorizationStoreFake) StoreRevocations(_ []byte, _ licensing.RevocationVerifier) (licensing.RevocationList, error) {
return licensing.RevocationList{}, errors.New("not used")
}
func (store *authorizationStoreFake) LoadRevocations(_ licensing.RevocationVerifier) (licensing.RevocationList, bool, error) {
if store.revokedErr != nil {
return licensing.RevocationList{}, false, store.revokedErr
}
list := store.revocations
list.RevokedLicenseIDs = append([]string(nil), list.RevokedLicenseIDs...)
return list, store.found, nil
}
+2
View File
@@ -198,6 +198,8 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
record.Entrypoint = expectation.App.Entrypoint
record.WorkingDirectory = extracted.WorkingDir
record.MinOS = expectation.App.MinOS
record.ProductID = extracted.ProductID
record.SupportsTrial = extracted.SupportsTrial
record.RequiresAdmin = expectation.App.RequiresAdmin
var recordWriteErr error
+2 -1
View File
@@ -46,7 +46,8 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
t.Fatalf("record = %#v", record)
}
if record.Entrypoint != "bin/App.exe" || record.WorkingDirectory != "." ||
record.MinOS != "windows-10" || record.RequiresAdmin {
record.MinOS != "windows-10" || record.ProductID != "test-product" ||
record.SupportsTrial || record.RequiresAdmin {
t.Fatalf("launch metadata = %#v", record)
}
if record.Files[0].Path != "bin/App.exe" || record.Files[0].Size != int64(len("new executable")) {
+22 -18
View File
@@ -14,20 +14,21 @@ import (
)
var (
ErrLaunchConfig = errors.New("invalid launch service configuration")
ErrLaunchRequest = errors.New("invalid launch request")
ErrAppNotInstalled = errors.New("app is not installed")
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
ErrCompatibilityCheck = errors.New("system compatibility check failed")
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
ErrAuthorizationCheck = errors.New("launch authorization check failed")
ErrLaunchUnauthorized = errors.New("launch is not authorized")
ErrTargetStateCheck = errors.New("launch target state check failed")
ErrAppRunning = errors.New("installed app is already running")
ErrProcessStart = errors.New("start installed app")
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
ErrLaunchConfig = errors.New("invalid launch service configuration")
ErrLaunchRequest = errors.New("invalid launch request")
ErrAppNotInstalled = errors.New("app is not installed")
ErrLaunchMetadata = errors.New("installed launch metadata is invalid")
ErrLaunchTargetUnsafe = errors.New("installed launch target is unsafe")
ErrEntrypointMissing = errors.New("installed entrypoint is missing")
ErrCompatibilityCheck = errors.New("system compatibility check failed")
ErrAppIncompatible = errors.New("installed app is incompatible with this system")
ErrAuthorizationCheck = errors.New("launch authorization check failed")
ErrLaunchUnauthorized = errors.New("launch is not authorized")
ErrTargetStateCheck = errors.New("launch target state check failed")
ErrAppRunning = errors.New("installed app is already running")
ErrProcessStart = errors.New("start installed app")
launchAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
launchProductIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
)
// FailureCode is the stable, non-localized result of a launch attempt.
@@ -91,10 +92,10 @@ type CompatibilityChecker interface {
IsCompatible(minOS string) (bool, error)
}
// AuthorizationChecker decides whether the user may launch one app. It is a
// required boundary; license policy is implemented by the later licensing task.
// AuthorizationChecker decides whether the user may launch one signed
// package product. It is a required fail-closed boundary.
type AuthorizationChecker interface {
IsAuthorized(appID string) (bool, error)
IsAuthorized(productID string) (bool, error)
}
// TargetStateChecker reports whether this precise entrypoint is running.
@@ -177,7 +178,10 @@ func (service *Service) Start(request Request) (Result, error) {
if !compatible {
return Result{}, launchError(ErrAppIncompatible)
}
authorized, err := service.authorization.IsAuthorized(record.ID)
if !launchProductIDPattern.MatchString(record.ProductID) {
return Result{}, launchError(ErrAuthorizationCheck)
}
authorized, err := service.authorization.IsAuthorized(record.ProductID)
if err != nil {
return Result{}, launchError(fmt.Errorf("%w: %w", ErrAuthorizationCheck, err))
}
+17
View File
@@ -13,6 +13,11 @@ func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
store, appRoot := seedInstalledApp(t)
launcher := &recordingLauncher{pid: 42}
service := newService(t, store, launcher)
var checkedProduct string
service.authorization = authorizationFunc(func(productID string) (bool, error) {
checkedProduct = productID
return true, nil
})
result, err := service.Start(Request{AppID: "test-app"})
if err != nil {
@@ -27,6 +32,9 @@ func TestServiceStartsOnlyVerifiedCurrentEntrypoint(t *testing.T) {
!launcher.command.RequiresAdmin {
t.Fatalf("command = %#v", launcher.command)
}
if checkedProduct != "test-product" {
t.Fatalf("authorization product = %q, want test-product", checkedProduct)
}
}
func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
@@ -49,6 +57,14 @@ func TestServiceRejectsUnsafeOrUnavailableLaunchStates(t *testing.T) {
wantErr: ErrLaunchMetadata,
wantCode: FailureCodeLaunchMetadataInvalid,
},
{
name: "missing legacy product metadata",
mutate: func(record *storage.InstalledApp, _ string) {
record.ProductID = ""
},
wantErr: ErrAuthorizationCheck,
wantCode: FailureCodeAuthorizationFailed,
},
{
name: "entrypoint is absent",
mutate: func(_ *storage.InstalledApp, appRoot string) {
@@ -202,6 +218,7 @@ func seedInstalledApp(t *testing.T) (*storage.InstalledAppStore, string) {
Entrypoint: "bin/App.exe",
WorkingDirectory: "bin",
MinOS: "windows-10",
ProductID: "test-product",
RequiresAdmin: true,
Files: []storage.InstalledFile{{
Path: "bin/App.exe",