Skip to content

[FEATURE]: plugins/builtins/ + cpex-builtins aggregator with feature-gated registration #72

@terylt

Description

@terylt

Problem

All plugin crates (apl-pii-scanner, apl-audit-logger, apl-identity-jwt,
apl-delegator-oauth, apl-pdp-*, apl-cedarling) live flat under crates/,
at the same level as the engine (cpex-core) and the policy layer
(apl-core/apl-cmf/apl-cpex). Two issues:

  1. No separation between "the engine" and "plugins that ship with it."
  2. Registration is hand-maintained in cpex-ffi/src/apl.rs — one
    register_factory(KIND, Box::new(Factory)) line per plugin — and every host
    pays for every plugin's compiled size, since the staticlib bundles them all.
    Only apl-cedarling is feature-gated today.

Proposal

1. Directory move (organizational only)

Split by extension axis — plugins and PDPs are different traits with different
registration paths, so the directory layout mirrors that seam:

builtins/
  plugins/            PluginFactory → register_factory()
    pii-scanner/      (was apl-pii-scanner)
    audit-logger/
    identity-jwt/
    delegator-oauth/
  pdps/               PdpFactory → AplOptions.pdp_factories
    cedar-direct/     (was apl-pdp-cedar-direct)
    cel/
  cedarling/          spans BOTH axes (identity + PDP seams) — kept at root
crates/
  cpex-core/          engine
  cpex-builtins/      NEW — aggregator + registration macro
  apl-core/ apl-cmf/ apl-cpex/   policy engine + bridge (NOT plugins)

Workspace members are path-based, so this is a pure rename — no compilation
impact. (Optional follow-up: drop the apl- prefix on plugin crates, since they
are CPEX plugins that use APL hooks, not APL itself.)

2. Packaging: each plugin is its own crate; one aggregator on top

  • Plugins stay individual crates — independent compilation units, isolated
    dep trees (cedarling's heavy deps only pulled when enabled), individually
    reusable.

  • cpex-builtins is the single crate a host depends on. Its [features]
    map 1:1 to plugin crates, plus convenience bundles:

    [features]
    default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth"]
    full    = ["default", "cedarling"]
    
    pii-scanner     = ["dep:cpex-plugin-pii-scanner"]
    audit-logger    = ["dep:cpex-plugin-audit-logger"]
    identity-jwt    = ["dep:cpex-plugin-identity-jwt"]
    delegator-oauth = ["dep:cpex-plugin-delegator-oauth"]
    cedarling       = ["dep:cpex-plugin-cedarling"]   # ~200 transitive deps

    One package to depend on, a feature set to pick what compiles in — without
    fusing plugins into a single crate.

Consumer side

A downstream Rust program selects exactly what it wants in its own
Cargo.toml. Take the defaults:

[dependencies]
cpex-builtins = "0.2"   # default = pii-scanner, audit-logger, identity-jwt, delegator-oauth

Or opt out of defaults and pick a minimal subset:

[dependencies]
cpex-builtins = { version = "0.2", default-features = false, features = ["pii-scanner"] }

Or take everything, including the heavy cedarling tree:

[dependencies]
cpex-builtins = { version = "0.2", features = ["full"] }

The host then calls cpex_builtins::register_builtins(&mgr) (and
builtin_pdps()) — the body is already #[cfg]-gated to exactly the features
selected above, so only the chosen plugins are compiled in and registered. No
code change between configurations, just the feature list.

3. Feature-gated registration (NOT inventory/linkme)

cpex-builtins exposes register_builtins(), generated by a declarative macro
that expands to #[cfg(feature = ...)]-gated, explicit register_factory
calls keyed off each crate's existing KIND const:

register_builtins! {
    feature "pii-scanner"     => cpex_plugin_pii_scanner::PiiScannerFactory,
    feature "audit-logger"    => cpex_plugin_audit_logger::AuditLoggerFactory,
    feature "identity-jwt"    => cpex_plugin_identity_jwt::JwtIdentityFactory,
    feature "delegator-oauth" => cpex_plugin_delegator_oauth::OAuthDelegatorFactory,
}

→ expands to:

pub fn register_builtins(mgr: &PluginManager) {
    #[cfg(feature = "pii-scanner")]
    mgr.register_factory(
        cpex_plugin_pii_scanner::KIND,
        Box::new(cpex_plugin_pii_scanner::PiiScannerFactory),
    );
    // ... one cfg-gated block per builtin
}

cpex_apl_install then collapses to a single
cpex_builtins::register_builtins(&mgr) call.

Why not inventory/linkme: they register via #[used] link-sections
discovered at startup. In a staticlib linked into a Go/C host, the linker
garbage-collects sections nothing references — auto-registered plugins silently
disappear. The current explicit design in apl.rs exists precisely to avoid
this. The macro keeps registration explicit (symbols survive) while making
inclusion feature-driven.

PDP factories are a separate axis (they flow through
apl_cpex::AplOptions.pdp_factories, not register_factory), so they get a
parallel register_builtin_pdps() rather than being mixed in.

Extending on top of builtins

Builtins are a convenient starting set, not a closed list.
register_builtins() is purely additive (each call is a HashMap insert in
PluginFactoryRegistry), so a host keeps registering its own factories on top:

// plugins
cpex_builtins::register_builtins(&mgr);                 // builtins
mgr.register_factory("acme/custom-guard",               // host's own
    Box::new(AcmeGuardFactory));

// pdps — builtin_pdps() returns an owned Vec the host can extend
let mut opts = apl_cpex::AplOptions::in_process();
opts.pdp_factories = cpex_builtins::builtin_pdps();      // builtins
opts.pdp_factories.push(Arc::new(AcmePdpFactory::new())); // host's own
apl_cpex::register_apl(&mgr, opts);

Collision handling. Registration is last-writer-wins on the kind string —
re-registering a builtin's kind overrides it. This is intentional (lets a host
swap a builtin's impl), but silent override is a footgun. register_factory
should log a warning when it overwrites an existing kind so accidental
shadowing is visible:

if self.factories.insert(kind.clone(), factory).is_some() {
    tracing::warn!(kind = %kind, "plugin factory overrides an existing registration");
}

(This path is Rust-host only. The FFI entry point bundles a fixed set for Go/C
hosts; custom registration over FFI is the deferred out-of-tree-plugin work.)

Rollout (incremental)

  1. Create cpex-builtins with the register_builtins! macro; have cpex-ffi
    delegate to it. No directory move yet — proves the mechanism in isolation,
    starting with one plugin (pii-scanner) as a proof-of-concept.
  2. Feature-gate the remaining builtins through cpex-builtins.
  3. Move plugin crates into plugins/builtins/ (pure rename).
  4. (Optional) drop the apl- prefix.

Out of scope

  • Dynamic/WASM/out-of-tree plugin loading (this is about built-in, compiled-in
    plugins).
  • Renaming the APL policy crates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No fields configured for Task.

    Projects

    Status
    Backlog

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions