419 lines
15 KiB
Go
419 lines
15 KiB
Go
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
|
||
|
|
}
|