mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 23:15:25 +08:00
* 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.
273 lines
7.8 KiB
Go
273 lines
7.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package internalplatform
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/hook"
|
|
)
|
|
|
|
// HookEntry is the displayable form of one registered hook.
|
|
type HookEntry struct {
|
|
Name string `json:"name"`
|
|
When string `json:"when,omitempty"` // observers only
|
|
Event string `json:"event,omitempty"` // lifecycle only
|
|
}
|
|
|
|
// PluginEntry collects everything one plugin contributed.
|
|
type PluginEntry struct {
|
|
Name string
|
|
Version string
|
|
Capabilities CapabilitiesView
|
|
|
|
// Rules holds the plugin's Restrict contributions, one per r.Restrict
|
|
// call (a plugin may declare several scoped rules). Empty when the
|
|
// plugin did not call r.Restrict.
|
|
Rules []*RuleView
|
|
|
|
Observers []HookEntry
|
|
Wrappers []HookEntry
|
|
Lifecycles []HookEntry
|
|
}
|
|
|
|
// CapabilitiesView mirrors platform.Capabilities for display. We keep a
|
|
// separate struct so the JSON shape stays under our control and does
|
|
// not drift with extension/platform.
|
|
type CapabilitiesView struct {
|
|
Restricts bool `json:"restricts"`
|
|
FailurePolicy string `json:"failure_policy"`
|
|
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
|
}
|
|
|
|
// NewCapabilitiesView converts a platform.Capabilities value into the
|
|
// display struct.
|
|
func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
|
|
return CapabilitiesView{
|
|
Restricts: c.Restricts,
|
|
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
|
RequiredCLIVersion: c.RequiredCLIVersion,
|
|
}
|
|
}
|
|
|
|
func failurePolicyLabel(p platform.FailurePolicy) string {
|
|
switch p {
|
|
case platform.FailOpen:
|
|
return "FailOpen"
|
|
case platform.FailClosed:
|
|
return "FailClosed"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RuleView is the displayable form of a Plugin.Restrict contribution.
|
|
type RuleView struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Allow []string `json:"allow"`
|
|
Deny []string `json:"deny"`
|
|
MaxRisk string `json:"max_risk"`
|
|
Identities []string `json:"identities"`
|
|
AllowUnannotated bool `json:"allow_unannotated"`
|
|
}
|
|
|
|
// Inventory is the full snapshot.
|
|
type Inventory struct {
|
|
Plugins []PluginEntry
|
|
}
|
|
|
|
// PluginInventorySource is the minimum slice of PluginInfo BuildInventory needs.
|
|
type PluginInventorySource struct {
|
|
Name string
|
|
Version string
|
|
Capabilities platform.Capabilities
|
|
}
|
|
|
|
// RuleInventorySource is the minimum slice of cmdpolicy.PluginRule
|
|
// BuildInventory needs. Kept as plain strings to avoid an import
|
|
// cycle with cmdpolicy (the caller converts platform.Risk / Identity
|
|
// to string at the boundary).
|
|
type RuleInventorySource struct {
|
|
PluginName string
|
|
Allow []string
|
|
Deny []string
|
|
MaxRisk string
|
|
Identities []string
|
|
RuleName string
|
|
Desc string
|
|
AllowUnannotated bool
|
|
}
|
|
|
|
// BuildInventory assembles an Inventory from the parts produced by
|
|
// InstallAll: the plugin metadata list, the hook registry (may be nil
|
|
// when no hooks were registered), and the plugin rules.
|
|
//
|
|
// Hooks are attributed to plugins by the namespaced name convention:
|
|
// each entry's Name starts with "<plugin>.", and we group by the
|
|
// leading segment up to the first dot.
|
|
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
|
|
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
|
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
|
for _, p := range plugins {
|
|
entry := PluginEntry{
|
|
Name: p.Name,
|
|
Version: p.Version,
|
|
Capabilities: NewCapabilitiesView(p.Capabilities),
|
|
}
|
|
out.Plugins = append(out.Plugins, entry)
|
|
}
|
|
for i := range out.Plugins {
|
|
byPlugin[out.Plugins[i].Name] = &out.Plugins[i]
|
|
}
|
|
|
|
if registry != nil {
|
|
for _, e := range registry.Observers() {
|
|
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
|
entry.Observers = append(entry.Observers, HookEntry{
|
|
Name: e.Name,
|
|
When: whenLabel(e.When),
|
|
})
|
|
}
|
|
}
|
|
for _, e := range registry.Wrappers() {
|
|
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
|
entry.Wrappers = append(entry.Wrappers, HookEntry{
|
|
Name: e.Name,
|
|
})
|
|
}
|
|
}
|
|
for _, e := range registry.Lifecycles() {
|
|
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
|
|
entry.Lifecycles = append(entry.Lifecycles, HookEntry{
|
|
Name: e.Name,
|
|
Event: eventLabel(e.Event),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, r := range rules {
|
|
if entry := byPlugin[r.PluginName]; entry != nil {
|
|
entry.Rules = append(entry.Rules, &RuleView{
|
|
Name: r.RuleName,
|
|
Description: r.Desc,
|
|
Allow: append([]string(nil), r.Allow...),
|
|
Deny: append([]string(nil), r.Deny...),
|
|
MaxRisk: r.MaxRisk,
|
|
Identities: append([]string(nil), r.Identities...),
|
|
AllowUnannotated: r.AllowUnannotated,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ownerOf extracts the plugin name from a namespaced hook name. The
|
|
// platform forbids "." in plugin names, so the first dot is always the
|
|
// namespace separator. Names without a dot are returned as-is.
|
|
func ownerOf(hookName string) string {
|
|
if i := strings.IndexByte(hookName, '.'); i >= 0 {
|
|
return hookName[:i]
|
|
}
|
|
return hookName
|
|
}
|
|
|
|
func whenLabel(w platform.When) string {
|
|
switch w {
|
|
case platform.Before:
|
|
return "Before"
|
|
case platform.After:
|
|
return "After"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func eventLabel(e platform.LifecycleEvent) string {
|
|
switch e {
|
|
case platform.Startup:
|
|
return "Startup"
|
|
case platform.Shutdown:
|
|
return "Shutdown"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- Active inventory storage (process-global) ---
|
|
|
|
var (
|
|
inventoryMu sync.RWMutex
|
|
activeInventory *Inventory
|
|
)
|
|
|
|
// SetActiveInventory records the inventory built at bootstrap. Called
|
|
// once from cmd/policy.go after install + wireHooks complete.
|
|
//
|
|
// A deep copy is taken so the snapshot is immune to later mutations of
|
|
// the input by the caller (or by any other goroutine reading the same
|
|
// PluginEntry slice). Without deep-copy, the shallow `cp := *inv`
|
|
// previously still aliased Plugins / observer / wrapper / lifecycle
|
|
// slices and the embedded RuleView's slice fields.
|
|
func SetActiveInventory(inv *Inventory) {
|
|
inventoryMu.Lock()
|
|
defer inventoryMu.Unlock()
|
|
if inv == nil {
|
|
activeInventory = nil
|
|
return
|
|
}
|
|
activeInventory = cloneInventory(inv)
|
|
}
|
|
|
|
// GetActiveInventory returns a deep copy of the inventory, or nil if
|
|
// bootstrap has not finished. Same reasoning as SetActiveInventory:
|
|
// returning a shallow copy would let callers reach into the stored
|
|
// global through any of the embedded slices.
|
|
func GetActiveInventory() *Inventory {
|
|
inventoryMu.RLock()
|
|
defer inventoryMu.RUnlock()
|
|
if activeInventory == nil {
|
|
return nil
|
|
}
|
|
return cloneInventory(activeInventory)
|
|
}
|
|
|
|
// cloneInventory deep-copies every level the snapshot exposes:
|
|
// top-level struct, Plugins slice, each PluginEntry's hook slices, and
|
|
// the rule's slice fields. The hook entries themselves are value types
|
|
// so the slice copy already disjoints them.
|
|
func cloneInventory(in *Inventory) *Inventory {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := &Inventory{
|
|
Plugins: make([]PluginEntry, len(in.Plugins)),
|
|
}
|
|
for i, p := range in.Plugins {
|
|
entry := PluginEntry{
|
|
Name: p.Name,
|
|
Version: p.Version,
|
|
Capabilities: p.Capabilities,
|
|
}
|
|
if p.Rules != nil {
|
|
entry.Rules = make([]*RuleView, len(p.Rules))
|
|
for j, r := range p.Rules {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
rv := *r
|
|
rv.Allow = append([]string(nil), r.Allow...)
|
|
rv.Deny = append([]string(nil), r.Deny...)
|
|
rv.Identities = append([]string(nil), r.Identities...)
|
|
entry.Rules[j] = &rv
|
|
}
|
|
}
|
|
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
|
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
|
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
|
out.Plugins[i] = entry
|
|
}
|
|
return out
|
|
}
|