Files
larksuite-cli/internal/platform/host_test.go
sang-neo03 50b3f0a2af feat(platform): support multiple policy rules per plugin (#1182)
* feat(platform): support multiple policy rules per plugin

Extend the command policy framework from single-Rule to multi-Rule
semantics. A plugin (or policy.yml) may now contribute several scoped
Rules; the engine combines them with OR -- a command is allowed when it
satisfies every axis of at least one rule. This lets one integration
apply different risk ceilings and identity restrictions to different
command groups.

The cross-plugin fail-closed boundary is preserved: two distinct plugins
both calling Restrict still aborts startup (multiple_restrict_plugins).
Single-Rule behaviour is fully backward compatible -- the rejection
reason_code / rule_name / envelope shape are byte-for-byte unchanged;
multi-rule rejection surfaces the aggregate reason_code no_matching_rule.

- engine: New keeps single-rule compat, add NewSet for OR over rules
- resolver: dedupe by owner (one plugin may contribute many rules),
  return []*Rule; yaml gains a top-level rules: list
- registrar/builder/staging: Restrict may be called more than once;
  retire the double_restrict error
- config policy show / config plugins show: emit a rules array
- inventory: PluginEntry.Rules is now a slice (fixes last-rule-wins
  overwrite when a plugin contributes multiple rules)

* fix(platform): clone rules in Builder.Restrict and inventory snapshot

Address review feedback. Builder.Restrict stored the caller's *Rule
directly, so reusing and mutating one Rule object across multiple
Restrict calls collapsed entries to the last mutation; clone the rule and
its slices on append, mirroring the staging registrar.

BuildInventory likewise reused the source Allow/Deny/Identities slices;
copy them when building the RuleView snapshot instead of relying on
cloneInventory downstream.

Add a regression test: reusing and mutating one Rule across two Restrict
calls now yields two independent rules.

* fix(platform): skip yaml when a plugin owns policy; reject empty rules list

Two policy-config robustness fixes from review:

- A malformed ~/.lark-cli/policy.yml could abort a plugin-governed
  binary. applyUserPolicyPruning read yaml before resolving, and
  build.go fail-closes on any policy error when a plugin is present.
  Plugin rules shadow yaml anyway, so skip reading yaml entirely when a
  plugin contributed rules -- an unrelated broken file on the user's
  machine can no longer lock the CLI.

- A present-but-empty "rules: []" collapsed to a single all-zero Rule
  that allows every annotated command ("looks like policy, enforces
  almost nothing"). yaml.Parse now distinguishes absent from
  present-but-empty (Rules is a pointer) and rejects the empty list.

Add regression tests for both.
2026-05-30 17:05:33 +08:00

434 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package internalplatform_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// happyPlugin is a textbook plugin: declares Capabilities, calls a few
// Registrar methods, returns nil. The install pipeline must accept it.
type happyPlugin struct{ name string }
func (p happyPlugin) Name() string { return p.name }
func (p happyPlugin) Version() string { return "1.0.0" }
func (p happyPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
FailurePolicy: platform.FailOpen,
}
}
func (p happyPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "audit-pre", platform.All(),
func(context.Context, platform.Invocation) {})
r.Wrap("policy", platform.All(),
func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return next(ctx, inv)
}
})
r.On(platform.Shutdown, "flush",
func(context.Context, *platform.LifecycleContext) error { return nil })
return nil
}
func TestInstallAll_happyPlugin(t *testing.T) {
result, err := internalplatform.InstallAll([]platform.Plugin{happyPlugin{name: "audit"}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if result.Registry == nil {
t.Fatalf("registry should be populated")
}
if len(result.PluginRules) != 0 {
t.Errorf("happy plugin did not call Restrict; rules should be empty")
}
// Cross-check: observers, wrappers, lifecycles got staged through to the live Registry.
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
t.Errorf("Before observer not committed")
}
if len(result.Registry.MatchingWrappers(fakeView{})) != 1 {
t.Errorf("Wrapper not committed")
}
if len(result.Registry.LifecycleHandlers(platform.Shutdown)) != 1 {
t.Errorf("Shutdown lifecycle not committed")
}
}
// fakeView satisfies platform.CommandView for selector lookups in the
// platformhost tests; All() matches everything so the type can stay
// trivial.
type fakeView struct{}
func (fakeView) Path() string { return "" }
func (fakeView) Domain() string { return "" }
func (fakeView) Risk() (platform.Risk, bool) { return "", false }
func (fakeView) Identities() []platform.Identity { return nil }
func (fakeView) Annotation(string) (string, bool) { return "", false }
// A FailClosed plugin whose Install returns an error must abort
// InstallAll. Design hard-constraint #6.
type failClosedPlugin struct{}
func (failClosedPlugin) Name() string { return "secaudit" }
func (failClosedPlugin) Version() string { return "1.0.0" }
func (failClosedPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
FailurePolicy: platform.FailClosed,
}
}
func (failClosedPlugin) Install(platform.Registrar) error {
return errors.New("upstream unreachable")
}
func TestInstallAll_failClosedAborts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{failClosedPlugin{}}, nil)
if err == nil {
t.Fatalf("FailClosed install error should abort")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("error must be *PluginInstallError, got %T", err)
}
if pi.ReasonCode != internalplatform.ReasonInstallFailed {
t.Errorf("ReasonCode = %q, want install_failed", pi.ReasonCode)
}
}
// FailOpen install failure logs a warning and skips this plugin; other
// plugins still get installed.
type failOpenPlugin struct{}
func (failOpenPlugin) Name() string { return "audit-broken" }
func (failOpenPlugin) Version() string { return "1.0.0" }
func (failOpenPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (failOpenPlugin) Install(platform.Registrar) error {
return errors.New("could not connect")
}
func TestInstallAll_failOpenSkips(t *testing.T) {
var buf bytes.Buffer
plugins := []platform.Plugin{
failOpenPlugin{},
happyPlugin{name: "audit"},
}
result, err := internalplatform.InstallAll(plugins, &buf)
if err != nil {
t.Fatalf("FailOpen failure must not abort, got %v", err)
}
if !strings.Contains(buf.String(), "audit-broken") {
t.Errorf("FailOpen warning should mention plugin name, got %q", buf.String())
}
// Second plugin's observer should be present.
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
t.Errorf("happy plugin's observer should still be installed after first plugin skipped")
}
}
// Restricts=true with FailOpen is a configuration error: a policy
// plugin that silently disappears under FailOpen would erase the
// security boundary. The host must reject this combo BEFORE Install
// runs.
type misconfiguredRestrictPlugin struct{}
func (misconfiguredRestrictPlugin) Name() string { return "secaudit" }
func (misconfiguredRestrictPlugin) Version() string { return "1.0.0" }
func (misconfiguredRestrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true, // policy plugin
FailurePolicy: platform.FailOpen, // contradicts safety contract
}
}
func (misconfiguredRestrictPlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_restrictsRequiresFailClosed(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{misconfiguredRestrictPlugin{}}, nil)
if err == nil {
t.Fatalf("Restricts+FailOpen must abort")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch {
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
}
}
// Restricts=true but Install didn't call r.Restrict -> mismatch.
type lyingRestrictPlugin struct{}
func (lyingRestrictPlugin) Name() string { return "p" }
func (lyingRestrictPlugin) Version() string { return "1.0.0" }
func (lyingRestrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
}
}
func (lyingRestrictPlugin) Install(platform.Registrar) error {
// Forgot to call r.Restrict.
return nil
}
func TestInstallAll_restrictsDeclaredButNotCalled(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{lyingRestrictPlugin{}}, nil)
if err == nil {
t.Fatalf("missing Restrict call when declared must fail")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch {
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
}
}
// Plugin that panics inside Install must NOT crash the binary -- the
// host recovers and converts the panic into a typed install_panic.
type panicInstallPlugin struct{}
func (panicInstallPlugin) Name() string { return "panicker" }
func (panicInstallPlugin) Version() string { return "1.0.0" }
func (panicInstallPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (panicInstallPlugin) Install(platform.Registrar) error {
panic("boom")
}
func TestInstallAll_installPanicRecovered(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{panicInstallPlugin{}}, nil)
if err == nil {
t.Fatalf("Install panic should surface as error")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInstallPanic {
t.Fatalf("ReasonCode = %v, want install_panic", pi)
}
}
// Two plugins with the same Name must abort before any Install runs.
func TestInstallAll_duplicatePluginName(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{
happyPlugin{name: "audit"},
happyPlugin{name: "audit"},
}, nil)
if err == nil {
t.Fatalf("duplicate Plugin.Name must abort")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicatePluginName {
t.Fatalf("ReasonCode = %v, want duplicate_plugin_name", pi)
}
}
// Plugin with an invalid Name (contains "." or starts with a hyphen)
// must abort with invalid_plugin_name. The dot ban is critical -- the
// "{plugin}.{hook}" namespace join would become ambiguous if dots were
// allowed inside Plugin.Name().
type badNamePlugin struct{ n string }
func (p badNamePlugin) Name() string { return p.n }
func (p badNamePlugin) Version() string { return "1.0.0" }
func (p badNamePlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (p badNamePlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_invalidPluginName(t *testing.T) {
cases := []string{"with.dot", "", "-leading-hyphen", "UPPER"}
for _, name := range cases {
t.Run(name, func(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{badNamePlugin{n: name}}, nil)
if err == nil {
t.Fatalf("invalid name %q should abort", name)
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidPluginName {
t.Fatalf("ReasonCode = %v, want invalid_plugin_name", pi)
}
})
}
}
// Plugin's Install registers two hooks with the same name -- the
// staging Registrar rejects the second one with duplicate_hook_name.
type duplicateHookPlugin struct{}
func (duplicateHookPlugin) Name() string { return "dup" }
func (duplicateHookPlugin) Version() string { return "1.0.0" }
func (duplicateHookPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (duplicateHookPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "x", platform.All(), func(context.Context, platform.Invocation) {})
r.Observe(platform.After, "x", platform.All(), func(context.Context, platform.Invocation) {})
return nil
}
func TestInstallAll_duplicateHookName(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{duplicateHookPlugin{}}, nil)
if err == nil {
t.Fatalf("duplicate hookName within same plugin must abort")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicateHookName {
t.Fatalf("ReasonCode = %v, want duplicate_hook_name", pi)
}
}
// Restrict contributes a rule to result.PluginRules so the pruning
// resolver can pick it up. Exercise the full path.
type restrictPlugin struct{ rule *platform.Rule }
func (p restrictPlugin) Name() string { return "secaudit" }
func (p restrictPlugin) Version() string { return "1.0.0" }
func (p restrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
}
}
func (p restrictPlugin) Install(r platform.Registrar) error {
r.Restrict(p.rule)
return nil
}
func TestInstallAll_restrictPropagatesRule(t *testing.T) {
rule := &platform.Rule{
Name: "secaudit-policy",
MaxRisk: "read",
Allow: []string{"docs/**"},
Deny: []string{"docs/+delete-doc"},
Identities: []platform.Identity{"bot"},
}
result, err := internalplatform.InstallAll([]platform.Plugin{restrictPlugin{rule: rule}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if len(result.PluginRules) != 1 {
t.Fatalf("expected 1 plugin rule, got %d", len(result.PluginRules))
}
stored := result.PluginRules[0].Rule
if stored == nil {
t.Fatalf("stored rule is nil")
}
// stagingRegistrar.Restrict defensively clones the plugin-supplied
// rule so a misbehaving plugin can't mutate it after Install
// returns. The clone must carry identical contents but live on a
// distinct pointer.
if stored == rule {
t.Errorf("stored rule should be a clone, got identical pointer")
}
if stored.Name != rule.Name || stored.MaxRisk != rule.MaxRisk {
t.Errorf("stored rule lost data: %+v", stored)
}
if got, want := len(stored.Allow), len(rule.Allow); got != want {
t.Errorf("stored Allow len = %d, want %d", got, want)
}
// Verify the clone is actually isolated: mutating the plugin's
// rule after install must not change the stored one.
rule.Allow[0] = "evil/**"
rule.Deny = append(rule.Deny, "extra/**")
if stored.Allow[0] == "evil/**" {
t.Errorf("Allow slice aliased plugin storage")
}
if len(stored.Deny) != 1 {
t.Errorf("Deny slice aliased plugin storage: %v", stored.Deny)
}
if result.PluginRules[0].PluginName != "secaudit" {
t.Errorf("PluginName = %q", result.PluginRules[0].PluginName)
}
}
// Atomic install: a plugin whose validation fails AFTER it registered
// some hooks must NOT leak those hooks into the live registry. The
// staging buffer is the atomicity boundary.
type partiallyRegisterThenFailPlugin struct{}
func (partiallyRegisterThenFailPlugin) Name() string { return "partial" }
func (partiallyRegisterThenFailPlugin) Version() string { return "1.0.0" }
func (partiallyRegisterThenFailPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true, // declares Restrict but won't call it
FailurePolicy: platform.FailClosed,
}
}
func (partiallyRegisterThenFailPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "would-leak", platform.All(),
func(context.Context, platform.Invocation) {})
// validateSelf will fail because Restricts=true but Restrict
// was not called -- this is the atomic-rollback case.
return nil
}
func TestInstallAll_atomicRollback(t *testing.T) {
_, err := internalplatform.InstallAll(
[]platform.Plugin{partiallyRegisterThenFailPlugin{}, happyPlugin{name: "audit"}},
nil,
)
if err == nil {
t.Fatalf("partial plugin should abort (FailClosed)")
}
// We cannot check Registry contents here because InstallAll
// returns nil on failure; the rollback invariant is "nothing the
// failing plugin staged ever reached a live Registry", which is
// proven by the fact that we got nil back. A weaker but useful
// check: even if we passed a happy second plugin, the loop must
// have stopped at the first FailClosed failure.
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("error must be *PluginInstallError, got %T", err)
}
}
// multiRestrictPlugin calls r.Restrict twice -- the multi-rule case. A
// single plugin may declare several scoped grants; both must be collected
// into PluginRules under the same plugin name, in registration order.
type multiRestrictPlugin struct{}
func (multiRestrictPlugin) Name() string { return "secaudit" }
func (multiRestrictPlugin) Version() string { return "1.0.0" }
func (multiRestrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
}
}
func (multiRestrictPlugin) Install(r platform.Registrar) error {
r.Restrict(&platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead})
r.Restrict(&platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: platform.RiskWrite})
return nil
}
// A single plugin calling Restrict more than once is valid (multi-rule
// support): both rules are collected, in order, under the one plugin name.
// This pins the behaviour change from the old "Restrict at most once"
// double_restrict error.
func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
result, err := internalplatform.InstallAll([]platform.Plugin{multiRestrictPlugin{}}, nil)
if err != nil {
t.Fatalf("multiple Restrict per plugin must succeed, got %v", err)
}
if len(result.PluginRules) != 2 {
t.Fatalf("PluginRules = %d, want 2", len(result.PluginRules))
}
for _, pr := range result.PluginRules {
if pr.PluginName != "secaudit" {
t.Errorf("PluginName = %q, want secaudit", pr.PluginName)
}
}
if result.PluginRules[0].Rule.Name != "docs-ro" || result.PluginRules[1].Rule.Name != "im-rw" {
t.Errorf("rules out of order: %q, %q",
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
}
}