Skip to content

Commit c5b2667

Browse files
committed
feat: add APL FFI and go bindings
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent ce31ae5 commit c5b2667

12 files changed

Lines changed: 379 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,33 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1717

1818
### Added
1919

20+
- APL (Attribute Policy Language) governance is now bundled into
21+
`libcpex_ffi.a`. New `cpex_apl_install` extern C entry point registers
22+
the standard APL plugin/PDP factories (`validator/pii-scan`,
23+
`audit/logger`, `identity/jwt`, `delegator/oauth`, `cedar-direct`) and
24+
installs the APL config visitor on a manager. Call it after
25+
`cpex_manager_new_default` and before `cpex_load_config`. Go hosts use
26+
`PluginManager.EnableAPL()`. The optional `cedarling` cargo feature adds
27+
the Cedarling-backed identity + PDP seams (off by default; the released
28+
`.a` stays lean).
2029
- Publish `libcpex_ffi.a` as signed GitHub Release artifacts on
2130
every semver tag push (`linux-amd64-gnu`, `linux-arm64-gnu`,
2231
`linux-amd64-musl`, `linux-arm64-musl`, `darwin-arm64`). Cosign
2332
keyless signatures + SHA256 checksums; see
2433
`crates/cpex-ffi/RELEASE.md` for the schema and the verify-and-
2534
consume recipe.
2635
- FFI ABI versioning: `cpex_ffi_abi_version()` extern C accessor
27-
exposes `FFI_ABI_VERSION` (currently `1`). The Go binding checks
28-
this in `init()` and panics on mismatch. Other language bindings
29-
must replicate the check.
36+
exposes `FFI_ABI_VERSION`. The Go binding checks this in `init()`
37+
and panics on mismatch. Other language bindings must replicate the
38+
check.
39+
40+
### Changed
41+
42+
- FFI `FFI_ABI_VERSION` bumped `1 → 2`: added the `cpex_apl_install`
43+
extern C function and changed `cpex_load_config` to run registered
44+
config visitors (it now calls `load_config_yaml` internally so `apl:`
45+
blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped
46+
in lockstep.
3047

3148
## [0.1.0] - 2026-05-05
3249

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cpex-ffi/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,31 @@ crate-type = ["lib", "cdylib", "staticlib"]
2020

2121
[dependencies]
2222
cpex-core = { path = "../cpex-core" }
23+
# APL governance layer — bundled so Go/Python hosts can enable APL
24+
# policies, route handlers, and the standard plugin/PDP factories via
25+
# the `cpex_apl_install` FFI entry point. Symbols survive in the
26+
# staticlib because that entry point references each factory.
27+
apl-cpex = { path = "../apl-cpex" }
28+
apl-pii-scanner = { path = "../apl-pii-scanner" }
29+
apl-audit-logger = { path = "../apl-audit-logger" }
30+
apl-identity-jwt = { path = "../apl-identity-jwt" }
31+
apl-delegator-oauth = { path = "../apl-delegator-oauth" }
32+
apl-pdp-cedar-direct = { path = "../apl-pdp-cedar-direct" }
33+
# Heavy (~200 transitive deps via the Cedarling git dep); kept out of the
34+
# default `.a` and behind the `cedarling` feature.
35+
apl-cedarling = { path = "../apl-cedarling", optional = true }
2336
tokio = { workspace = true }
2437
serde = { workspace = true }
2538
serde_json = { workspace = true }
2639
rmp-serde = { workspace = true }
2740
serde_bytes = { workspace = true }
2841
tracing = { workspace = true }
2942

43+
[features]
44+
default = []
45+
# Opt-in Cedarling-backed identity + PDP. Build with
46+
# `cargo build -p cpex-ffi --features cedarling`.
47+
cedarling = ["dep:apl-cedarling"]
48+
3049
[dev-dependencies]
3150
async-trait = { workspace = true }

crates/cpex-ffi/RELEASE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ without needing a Rust toolchain.
88
This document covers what is published, how to consume and verify
99
an artifact, and the FFI ABI policy that makes the contract durable.
1010

11+
> **APL bundled.** The published `.a` includes the APL (Attribute Policy
12+
> Language) governance layer and its standard plugin/PDP factories
13+
> (`validator/pii-scan`, `audit/logger`, `identity/jwt`,
14+
> `delegator/oauth`, `cedar-direct`). Enable it on a manager via
15+
> `cpex_apl_install` (Go: `PluginManager.EnableAPL()`) after
16+
> `cpex_manager_new_default` and before `cpex_load_config`. The
17+
> Cedarling-backed seams are **not** in the default `.a` — build with
18+
> `cargo build -p cpex-ffi --features cedarling` to include them.
19+
1120
## What is published
1221

1322
Every CPEX release tagged `vMAJOR.MINOR.PATCH` (or

crates/cpex-ffi/src/apl.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Location: ./crates/cpex-ffi/src/apl.rs
2+
// Copyright 2025
3+
// SPDX-License-Identifier: Apache-2.0
4+
// Authors: Teryl Taylor
5+
//
6+
// APL (Attribute Policy Language) FFI wiring.
7+
//
8+
// `cpex_apl_install` registers the bundled APL plugin factories and
9+
// installs the APL config visitor on a manager so that a subsequent
10+
// `cpex_load_config` walks `apl:` blocks and installs per-route handlers.
11+
//
12+
// Registration is explicit (no inventory/ctor magic): each factory is
13+
// referenced here so its object code survives in `libcpex_ffi.a`. Adding
14+
// a new bundled factory means adding a `register_factory` call below.
15+
//
16+
// Ordering: call AFTER `cpex_manager_new_default` and BEFORE
17+
// `cpex_load_config`. The config visitor must be registered before the
18+
// config is loaded, and the one-shot `cpex_manager_new(yaml)` path loads
19+
// during construction — so APL is only supported via the default-manager
20+
// flow:
21+
//
22+
// cpex_manager_new_default
23+
// → cpex_apl_install
24+
// → cpex_load_config
25+
// → cpex_initialize
26+
27+
use std::os::raw::c_int;
28+
use std::panic::{catch_unwind, AssertUnwindSafe};
29+
use std::sync::Arc;
30+
31+
use crate::{CpexManagerInner, RC_INVALID_HANDLE, RC_OK, RC_PANIC};
32+
33+
/// Register the bundled APL plugin factories and install the APL config
34+
/// visitor (in-process defaults: memory session store, default baseline
35+
/// capabilities) on `mgr`.
36+
///
37+
/// Bundled plugin factories (registered by `kind`):
38+
/// - `validator/pii-scan` → apl-pii-scanner
39+
/// - `audit/logger` → apl-audit-logger
40+
/// - `identity/jwt` → apl-identity-jwt
41+
/// - `delegator/oauth` → apl-delegator-oauth
42+
///
43+
/// Bundled PDP factory (consulted for `global.apl.pdp[]` entries):
44+
/// - `cedar-direct` → apl-pdp-cedar-direct
45+
///
46+
/// With the `cedarling` cargo feature, the Cedarling-backed identity and
47+
/// PDP seams are additionally wired.
48+
///
49+
/// Returns `RC_OK` on success, `RC_INVALID_HANDLE` if `mgr` is null, or
50+
/// `RC_PANIC` if registration panicked (caught at the FFI boundary).
51+
///
52+
/// # Safety
53+
/// `mgr` must be a valid handle returned by `cpex_manager_new_default`
54+
/// (or `cpex_manager_new`) and not yet shut down.
55+
#[no_mangle]
56+
pub unsafe extern "C" fn cpex_apl_install(mgr: *const CpexManagerInner) -> c_int {
57+
let inner = match mgr.as_ref() {
58+
Some(m) => m,
59+
None => return RC_INVALID_HANDLE,
60+
};
61+
62+
let result = catch_unwind(AssertUnwindSafe(|| {
63+
// Plugin factories — registered by `kind` string. Must happen
64+
// before load_config so the manager can instantiate plugins whose
65+
// YAML `kind:` matches.
66+
inner.manager.register_factory(
67+
apl_pii_scanner::KIND,
68+
Box::new(apl_pii_scanner::PiiScannerFactory),
69+
);
70+
inner.manager.register_factory(
71+
apl_audit_logger::KIND,
72+
Box::new(apl_audit_logger::AuditLoggerFactory),
73+
);
74+
inner.manager.register_factory(
75+
apl_identity_jwt::KIND,
76+
Box::new(apl_identity_jwt::JwtIdentityFactory),
77+
);
78+
inner.manager.register_factory(
79+
apl_delegator_oauth::KIND,
80+
Box::new(apl_delegator_oauth::OAuthDelegatorFactory),
81+
);
82+
83+
// APL config visitor + PDP factories. `pdp_factories` are consulted
84+
// for `global.apl.pdp[]` entries; cedar-direct is the bundled
85+
// default. The visitor keeps a Weak<PluginManager> (see
86+
// CpexManagerInner) that upgrades during load_config_yaml.
87+
let mut opts = apl_cpex::AplOptions::in_process();
88+
opts.pdp_factories =
89+
vec![Arc::new(apl_pdp_cedar_direct::CedarDirectPdpFactory::new())];
90+
91+
apl_cpex::register_apl(&inner.manager, opts);
92+
}));
93+
94+
match result {
95+
Ok(()) => RC_OK,
96+
Err(_panic) => {
97+
tracing::error!("cpex_apl_install: panic caught at FFI boundary");
98+
RC_PANIC
99+
}
100+
}
101+
}

crates/cpex-ffi/src/lib.rs

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use std::os::raw::{c_char, c_int};
1616
use std::panic::{catch_unwind, AssertUnwindSafe};
1717
use std::ptr;
18-
use std::sync::OnceLock;
18+
use std::sync::{Arc, OnceLock};
1919
use std::time::Duration;
2020

2121
use cpex_core::context::PluginContextTable;
@@ -24,6 +24,9 @@ use cpex_core::extensions::Extensions;
2424
use cpex_core::hooks::payload::PluginPayload;
2525
use cpex_core::manager::PluginManager;
2626

27+
// APL governance wiring — the `cpex_apl_install` extern "C" entry point.
28+
mod apl;
29+
2730
// ---------------------------------------------------------------------------
2831
// FFI Result Codes
2932
// ---------------------------------------------------------------------------
@@ -90,7 +93,7 @@ pub const RC_PANIC: c_int = -7;
9093

9194
/// FFI ABI version. Bump on breaking C-surface changes; see module
9295
/// docs above for what counts as breaking.
93-
pub const FFI_ABI_VERSION: u32 = 1;
96+
pub const FFI_ABI_VERSION: u32 = 2;
9497

9598
/// Returns the FFI ABI version this `libcpex_ffi` was built with.
9699
/// Language bindings call this at `init` and panic on mismatch
@@ -376,7 +379,11 @@ fn serialize_payload(payload: &dyn PluginPayload) -> Result<(u8, Vec<u8>), Strin
376379
/// All managers share the process-singleton runtime returned by
377380
/// `shared_runtime()` — see the `SHARED_RUNTIME` doc-comment for why.
378381
pub struct CpexManagerInner {
379-
pub manager: PluginManager,
382+
/// Held as `Arc` so the APL config visitor — registered via
383+
/// `cpex_apl_install` — can keep a `Weak<PluginManager>` that upgrades
384+
/// during `load_config_yaml`. See `apl::cpex_apl_install` and
385+
/// `apl_cpex::register_apl`.
386+
pub manager: Arc<PluginManager>,
380387
}
381388

382389
/// Opaque handle to a ContextTable (Rust-owned, not serialized).
@@ -475,9 +482,12 @@ pub unsafe extern "C" fn cpex_manager_new(
475482
// silently no-op.
476483
let _ = shared_runtime();
477484

478-
let manager = PluginManager::default();
485+
let manager = Arc::new(PluginManager::default());
479486

480-
// Load config — factories must be registered separately via cpex_register_factory
487+
// Load config — factories must be registered separately via cpex_register_factory.
488+
// Note: this one-shot path uses `load_config` (no visitor walk), so APL is
489+
// NOT wired here. APL requires the cpex_manager_new_default →
490+
// cpex_apl_install → cpex_load_config flow.
481491
if let Err(e) = manager.load_config(cpex_config) {
482492
tracing::error!("cpex_manager_new: load_config failed: {}", e);
483493
return ptr::null_mut();
@@ -492,7 +502,7 @@ pub unsafe extern "C" fn cpex_manager_new(
492502
#[no_mangle]
493503
pub extern "C" fn cpex_manager_new_default() -> *mut CpexManagerInner {
494504
let _ = shared_runtime();
495-
let manager = PluginManager::default();
505+
let manager = Arc::new(PluginManager::default());
496506
Box::into_raw(Box::new(CpexManagerInner { manager }))
497507
}
498508

@@ -523,17 +533,22 @@ pub unsafe extern "C" fn cpex_load_config(
523533
None => return RC_INVALID_INPUT,
524534
};
525535

526-
let cpex_config = match cpex_core::config::parse_config(yaml) {
527-
Ok(c) => c,
528-
Err(e) => {
529-
tracing::error!("cpex_load_config: config parse failed: {}", e);
530-
return RC_PARSE_ERROR;
531-
}
532-
};
536+
// Validate first (duplicate plugin names, route shape) — preserves the
537+
// RC_PARSE_ERROR contract. We discard the parsed value and hand the raw
538+
// YAML to `load_config_yaml`, which re-parses into both a typed
539+
// CpexConfig and a raw serde_yaml::Value so registered config visitors
540+
// (e.g. the APL visitor installed by cpex_apl_install) can walk the
541+
// `apl:` blocks and install per-route handlers. Plain `load_config`
542+
// does NOT run that visitor walk.
543+
if let Err(e) = cpex_core::config::parse_config(yaml) {
544+
tracing::error!("cpex_load_config: config parse failed: {}", e);
545+
return RC_PARSE_ERROR;
546+
}
533547

534-
// load_config is sync (no .await), but we still wrap in catch_unwind
535-
// so a panic in serde / config validation doesn't unwind across FFI.
536-
let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config(cpex_config)));
548+
// load_config_yaml is sync (no .await), but we still wrap in catch_unwind
549+
// so a panic in serde / config validation / a visitor doesn't unwind
550+
// across FFI.
551+
let load_result = catch_unwind(AssertUnwindSafe(|| inner.manager.load_config_yaml(yaml)));
537552
match load_result {
538553
Ok(Ok(())) => RC_OK,
539554
Ok(Err(e)) => {
@@ -1093,7 +1108,7 @@ mod tests {
10931108
// Touch the shared runtime so it's initialized; tests use it
10941109
// rather than a per-manager runtime.
10951110
let _ = shared_runtime();
1096-
let manager = cpex_core::manager::PluginManager::default();
1111+
let manager = Arc::new(cpex_core::manager::PluginManager::default());
10971112
Box::into_raw(Box::new(CpexManagerInner { manager }))
10981113
}
10991114

@@ -1354,4 +1369,53 @@ mod tests {
13541369
assert_eq!(cpex_is_initialized(ptr::null()), 0);
13551370
}
13561371
}
1372+
1373+
#[test]
1374+
fn cpex_apl_install_rejects_null_handle() {
1375+
unsafe {
1376+
assert_eq!(crate::apl::cpex_apl_install(ptr::null()), RC_INVALID_HANDLE);
1377+
}
1378+
}
1379+
1380+
/// Full APL flow through the FFI surface: default manager →
1381+
/// cpex_apl_install (registers bundled factories + APL visitor) →
1382+
/// cpex_load_config over an `apl:`-annotated YAML using a bundled
1383+
/// plugin kind (`audit/logger`) → cpex_initialize. Proves the visitor
1384+
/// walk runs (load uses load_config_yaml) and the bundled factory is
1385+
/// reachable, so the plugin actually instantiates.
1386+
#[test]
1387+
fn cpex_apl_install_then_load_apl_config_initializes() {
1388+
const YAML: &str = r#"
1389+
plugins:
1390+
- name: auditor
1391+
kind: audit/logger
1392+
hooks: [cmf.tool_pre_invoke]
1393+
routes:
1394+
- tool: get_weather
1395+
apl:
1396+
policy:
1397+
- "plugin(auditor)"
1398+
"#;
1399+
unsafe {
1400+
let mgr = build_test_manager();
1401+
1402+
assert_eq!(crate::apl::cpex_apl_install(mgr), RC_OK);
1403+
1404+
let rc = cpex_load_config(mgr, YAML.as_ptr() as *const c_char, YAML.len() as c_int);
1405+
assert_eq!(rc, RC_OK, "load of APL config should succeed");
1406+
1407+
assert_eq!(cpex_initialize(mgr), RC_OK);
1408+
1409+
// The bundled `audit/logger` factory instantiated a plugin on
1410+
// cmf.tool_pre_invoke — proves cpex_apl_install wired the kind.
1411+
assert!(cpex_plugin_count(mgr) >= 1);
1412+
let hook = "cmf.tool_pre_invoke";
1413+
assert_eq!(
1414+
cpex_has_hooks_for(mgr, hook.as_ptr() as *const c_char, hook.len() as c_int),
1415+
1,
1416+
);
1417+
1418+
cpex_shutdown(mgr);
1419+
}
1420+
}
13571421
}

examples/go-demo/ffi/src/cmf_plugins.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ impl PluginFactory for HeaderInjectorFactory {
265265
}
266266

267267
/// Register CMF demo plugin factories on a manager.
268-
pub fn register_cmf_factories(manager: &mut cpex_core::manager::PluginManager) {
268+
pub fn register_cmf_factories(manager: &cpex_core::manager::PluginManager) {
269269
manager.register_factory("builtin/cmf-tool-policy", Box::new(ToolPolicyFactory));
270270
manager.register_factory(
271271
"builtin/cmf-header-injector",

examples/go-demo/ffi/src/demo_plugins.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ impl PluginFactory for AuditLoggerFactory {
268268
}
269269

270270
/// Register all demo plugin factories on a manager.
271-
pub fn register_demo_factories(manager: &mut cpex_core::manager::PluginManager) {
271+
pub fn register_demo_factories(manager: &cpex_core::manager::PluginManager) {
272272
manager.register_factory("builtin/identity", Box::new(IdentityCheckerFactory));
273273
manager.register_factory("builtin/pii", Box::new(PiiGuardFactory));
274274
manager.register_factory("builtin/audit", Box::new(AuditLoggerFactory));

examples/go-demo/ffi/src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ use std::os::raw::c_int;
4545
pub unsafe extern "C" fn cpex_demo_register_factories(
4646
mgr: *mut cpex_ffi::CpexManagerInner,
4747
) -> c_int {
48-
let inner = match mgr.as_mut() {
48+
let inner = match mgr.as_ref() {
4949
Some(m) => m,
5050
None => return -1,
5151
};
5252

53-
demo_plugins::register_demo_factories(&mut inner.manager);
54-
cmf_plugins::register_cmf_factories(&mut inner.manager);
53+
// `register_factory` takes `&self`; `&inner.manager` deref-coerces
54+
// from `Arc<PluginManager>` to `&PluginManager`.
55+
demo_plugins::register_demo_factories(&inner.manager);
56+
cmf_plugins::register_cmf_factories(&inner.manager);
5557
0
5658
}

0 commit comments

Comments
 (0)