225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
package policy
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ErrUnsupportedDomain is returned when a policy domain has no simulation
|
||
|
|
// decision registered.
|
||
|
|
var ErrUnsupportedDomain = errors.New("unsupported policy domain")
|
||
|
|
|
||
|
|
var domainDecisions = map[string]DecisionName{
|
||
|
|
DomainScope: DecisionScope,
|
||
|
|
DomainPermission: DecisionPermission,
|
||
|
|
DomainAction: DecisionAction,
|
||
|
|
}
|
||
|
|
|
||
|
|
// DecisionTypes returns policy domains with simulation support.
|
||
|
|
func DecisionTypes() []string {
|
||
|
|
types := make([]string, 0, len(domainDecisions))
|
||
|
|
for domain := range domainDecisions {
|
||
|
|
types = append(types, domain)
|
||
|
|
}
|
||
|
|
sort.Strings(types)
|
||
|
|
return types
|
||
|
|
}
|
||
|
|
|
||
|
|
// SimulateRequest describes a stateless policy simulation request.
|
||
|
|
type SimulateRequest struct {
|
||
|
|
Domain string `json:"domain"`
|
||
|
|
Source string `json:"source,omitempty"`
|
||
|
|
Input json.RawMessage `json:"input"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// SimulateResult is the raw policy decision produced by a throwaway engine.
|
||
|
|
type SimulateResult struct {
|
||
|
|
Decision json.RawMessage `json:"decision"`
|
||
|
|
EvalTimeNS int64 `json:"eval_time_ns"`
|
||
|
|
Generation int64 `json:"generation"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Simulate evaluates a policy decision against a throwaway engine. It never
|
||
|
|
// mutates the live System engine and never writes a decision-log entry.
|
||
|
|
func Simulate(ctx context.Context, store *PolicyStore, req SimulateRequest) (SimulateResult, error) {
|
||
|
|
domain := strings.TrimSpace(req.Domain)
|
||
|
|
decisionName, ok := domainDecisions[domain]
|
||
|
|
if !ok {
|
||
|
|
return SimulateResult{}, fmt.Errorf("%w: %s", ErrUnsupportedDomain, domain)
|
||
|
|
}
|
||
|
|
|
||
|
|
input, err := decodeSimulateInput(req.Input)
|
||
|
|
if err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
sources, generation, err := simulationSources(ctx, store)
|
||
|
|
if err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(req.Source) != "" {
|
||
|
|
if err := CompileCheck(ctx, domain, req.Source); err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
sources[domain] = ActiveSource{Source: req.Source}
|
||
|
|
}
|
||
|
|
|
||
|
|
engine := newEngine(WithRevision(generation))
|
||
|
|
// Lenient on stored sources: a broken source in another domain must not
|
||
|
|
// block simulating a fix. The candidate source itself was CompileChecked.
|
||
|
|
modules, _, err := engine.modulesWithCustom(ctx, sources)
|
||
|
|
if err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
if err := engine.swap(ctx, modules, decisionQueries(), generation); err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var decision json.RawMessage
|
||
|
|
meta, err := engine.Evaluate(ctx, decisionName, input, &decision)
|
||
|
|
if err != nil {
|
||
|
|
return SimulateResult{}, err
|
||
|
|
}
|
||
|
|
return SimulateResult{
|
||
|
|
Decision: decision,
|
||
|
|
EvalTimeNS: meta.EvalTimeNS,
|
||
|
|
Generation: meta.Revision,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GuardEvalCost is the activation-time cost guard: it compiles the vendor
|
||
|
|
// bundle plus source into a throwaway engine and evaluates the domain's
|
||
|
|
// decision once against a canned representative input under budget. Every
|
||
|
|
// authenticated request evaluates policy under the same budget, so activating
|
||
|
|
// a source that cannot complete within it would convert to request-path
|
||
|
|
// failures for every viewer; rejecting at activation keeps that blast radius
|
||
|
|
// at the admin who authored the policy.
|
||
|
|
func GuardEvalCost(ctx context.Context, domain, source string, budget time.Duration) error {
|
||
|
|
domain = strings.TrimSpace(domain)
|
||
|
|
decisionName, ok := domainDecisions[domain]
|
||
|
|
if !ok {
|
||
|
|
return fmt.Errorf("%w: %s", ErrUnsupportedDomain, domain)
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(source) == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if budget <= 0 {
|
||
|
|
budget = defaultEvalTimeout
|
||
|
|
}
|
||
|
|
|
||
|
|
input, err := guardBenchmarkInput(domain)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
modules, err := vendorModules(false)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
modules = append(modules, ModuleSource{Path: customModulePath(domain), Source: source})
|
||
|
|
engine := newEngine(WithEvalTimeout(budget))
|
||
|
|
if err := engine.swap(ctx, modules, decisionQueries(), 0); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
var decision json.RawMessage
|
||
|
|
if _, err := engine.Evaluate(ctx, decisionName, input, &decision); err != nil {
|
||
|
|
if errors.Is(err, ErrPolicyEvalTimeout) {
|
||
|
|
return fmt.Errorf("%w: %s decision did not complete within %s", ErrPolicySlowEval, domain, budget)
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// guardBenchmarkInput returns a fully-populated representative input for the
|
||
|
|
// domain's decision. It only needs to reach the custom override with realistic
|
||
|
|
// fact shapes — the decision outcome is irrelevant to the cost measurement.
|
||
|
|
func guardBenchmarkInput(domain string) (any, error) {
|
||
|
|
const benchTime = "2026-01-01T00:00:00Z"
|
||
|
|
var input any
|
||
|
|
switch domain {
|
||
|
|
case DomainScope:
|
||
|
|
input = ScopeInput{
|
||
|
|
SchemaVersion: 1, UserID: 1, SessionID: "eval-cost-guard",
|
||
|
|
AccountLibraryIDs: []int{1, 2, 3}, AccountRestricted: true,
|
||
|
|
AccountMaxQuality: "2160p", AccessPolicyRevision: 1,
|
||
|
|
DisabledLibraryIDs: []int{4}, ProfilePresent: true,
|
||
|
|
ProfileMaxRating: "PG-13", ProfileMaxQuality: "1080p",
|
||
|
|
ProfileLibraryLimited: true, ProfileLibraryIDs: []int{1, 2},
|
||
|
|
ProfileHasPIN: true, ProfileVerified: true, RequestTime: benchTime,
|
||
|
|
}
|
||
|
|
case DomainPermission:
|
||
|
|
input = PermissionInput{
|
||
|
|
SchemaVersion: 1, UserID: 1, Role: "user", UserEnabled: true,
|
||
|
|
AssignedPermissions: []string{PermissionMarkerEdit},
|
||
|
|
Permission: PermissionMarkerEdit,
|
||
|
|
TargetLibraryIDs: []int{1}, UserLibraryIDs: []int{1, 2},
|
||
|
|
UserLibrariesRestricted: true, RequestTime: benchTime,
|
||
|
|
}
|
||
|
|
case DomainAction:
|
||
|
|
input = ActionInput{
|
||
|
|
SchemaVersion: 1, Action: ActionDownload, UserID: 1,
|
||
|
|
DownloadAllowed: true, DownloadTranscodeAllowed: true,
|
||
|
|
MaxStreams: 5, MaxTranscodes: 2, DownloadsEnabled: true,
|
||
|
|
TranscodeEnabled: true, ArtifactsAvailable: true,
|
||
|
|
CurrentActiveStreams: 1, RequestedAction: RequestedActionDirectPlay,
|
||
|
|
MaxPlaybackQuality: "1080p", RequestTime: benchTime,
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedDomain, domain)
|
||
|
|
}
|
||
|
|
// Round-trip so the engine sees the same JSON document shape live inputs
|
||
|
|
// arrive as.
|
||
|
|
raw, err := json.Marshal(input)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("encoding eval-cost guard input: %w", err)
|
||
|
|
}
|
||
|
|
var decoded any
|
||
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||
|
|
return nil, fmt.Errorf("decoding eval-cost guard input: %w", err)
|
||
|
|
}
|
||
|
|
return decoded, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeSimulateInput(raw json.RawMessage) (any, error) {
|
||
|
|
if len(raw) == 0 {
|
||
|
|
return nil, compileErrorMessage("policy simulation input is required")
|
||
|
|
}
|
||
|
|
var input any
|
||
|
|
if err := json.Unmarshal(raw, &input); err != nil {
|
||
|
|
return nil, compileErrorMessage("policy simulation input must be valid JSON")
|
||
|
|
}
|
||
|
|
return input, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func simulationSources(ctx context.Context, store *PolicyStore) (map[string]ActiveSource, int64, error) {
|
||
|
|
if store == nil {
|
||
|
|
return map[string]ActiveSource{}, 0, nil
|
||
|
|
}
|
||
|
|
for {
|
||
|
|
before, err := store.Generation(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
sources, err := store.ActiveSources(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
after, err := store.Generation(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
if before == after {
|
||
|
|
return sources, after, nil
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|