Files

225 lines
7.0 KiB
Go
Raw Permalink Normal View History

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
}
}
}