Skip to content

refactor(apl)!: rename authz/authn config keys #111

Open
araujof wants to merge 4 commits into
devfrom
refactor/apl_syntax
Open

refactor(apl)!: rename authz/authn config keys #111
araujof wants to merge 4 commits into
devfrom
refactor/apl_syntax

Conversation

@araujof

@araujof araujof commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Renames the CPEX/APL authorization/authentication config keys for clarity. Breaking change (pre-1.0): old key names no longer parse.

Old New
identity: authentication: (global, route, policy-group)
policy: authorization.pre_invocation: (or flat pre_invocation:)
post_policy: authorization.post_invocation: (or flat post_invocation:)

The two authorization phases parse equivalently whether nested under an authorization: block or written flat on the section.

Closes #105.

Deliberately not renamed

The field-pipeline keys args: / result: stay as-is. They're aligned with the args.* / result.* attribute namespaces that predicates and interpolation read (require(args.include_ssn), ${args.repo_name}, args.ssn | redact) — renaming only the config block would have introduced a new inconsistency. (The issue originally proposed inputs/outputs; we tried it and rolled it back for exactly this reason.)

Internal APL IR (CompiledRoute, Phase) and the runtime attribute namespaces are unchanged.

Fail-closed by design

Legacy policy: / post_policy: / identity: keys are rejected loudly at parse/load time (in both the apl-core parser and cpex-core config loader) rather than silently ignored — a dropped authorization or authentication block would otherwise fail open.

Example

routes:
  - tool: get_employee
    authentication: [jwt-user, jwt-client]
    args:
      employee_id: "str"
    authorization:
      pre_invocation:
        - "require(authenticated)"
        - "delegation.depth > 2: deny"
      post_invocation:
        - "run(audit-log)"
    result:
      ssn: "str | redact(!perm.view_ssn)"

Also

  • APL acronym spelled consistently as "Authorization Policy Language" (dropped the legacy "Attribute Policy Language").
  • Docs, README, and CHANGELOG updated with the new surface and a BREAKING migration entry.

Testing

  • cargo fmt --check, cargo clippy --workspace --all-targets, and full cargo test --workspace (all 55 suites) pass.
  • Added coverage for nested-vs-flat equivalence and legacy-key rejection.
  • Praxis regression: built praxis against this checkout (path dep) — compiles with no API/ABI break and its cpex policy integration test passes (401/200). The praxis-demos/demos/cpex configs were migrated to the new surface (separate PR in that repo) and verified to load through install_builtins + load_config_yaml.

araujof added 3 commits June 30, 2026 22:45
Rename the CPEX/APL config keys for clarity (breaking change):

  identity    -> authentication  (global, route, and policy-group scope)
  policy      -> authorization.pre_invocation  (or flat pre_invocation)
  post_policy -> authorization.post_invocation (or flat post_invocation)

The two authorization phases parse equivalently whether written nested
under an `authorization:` block or flat on the section.

The field-pipeline keys `args:` / `result:` are intentionally left
unchanged: they stay aligned with the `args.*` / `result.*` attribute
namespaces that predicates and interpolation read, so renaming only the
config block would have introduced a new inconsistency.

Legacy `policy:` / `post_policy:` / `identity:` keys are rejected loudly
at parse/load time rather than silently ignored, so a dropped
authorization or authentication block can never fail open. Internal APL
IR (`CompiledRoute`, `Phase`) is unchanged.

Refs #105

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Update every config example and field reference to the renamed keys
(authentication, authorization.pre_invocation / post_invocation), show
both the nested and flat authorization forms, and add a BREAKING
migration entry to the changelog. The `args:` / `result:` field-pipeline
keys and the `args.*` / `result.*` attribute vocabulary are unchanged.

Refs #105

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Replace the legacy "Attribute Policy Language" expansion with
"Authorization Policy Language" across code doc-comments, the crate
description, and docs, matching the README and 0.1.x overview.

Refs #105

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof araujof requested review from jonpspri and terylt as code owners July 1, 2026 02:54
@araujof araujof added this to CPEX Jul 1, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Jul 1, 2026
@araujof araujof added this to the 0.2.1 milestone Jul 1, 2026
@araujof araujof added enhancement New feature or request framework Rust labels Jul 1, 2026
@araujof araujof changed the title refactor(apl)!: rename authz/authn config keys (identity→authentication, policy→authorization.pre/post_invocation) refactor(apl)!: rename authz/authn config keys Jul 1, 2026
@terylt

terylt commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Reviewed the rename mechanics end-to-end (parser, config loader, visitor, FFI/Go, docs). The core is solid and the fail-closed posture is real — a couple of things below, one of which I'd fix before merge.

What checks out

  • Legacy rejection is thorough. policy: / post_policy: are rejected at every scope in both the apl-core parser (reject_legacy_keys in compile_route + compile_apl_blocks) and the cpex-core visitor (reject_legacy_apl_keys at global / default / policy-group / route). identity: is rejected at global, global.policies.*, global.defaults.*, and each route in reject_renamed_identity_key, which is actually called (config.rs:628) before validation. No scope silently drops a legacy key at the top level.
  • FFI / Go bindings carry no stale config keys — the only identity left is the plugin kind identity/jwt, which is correctly untouched.
  • Docs are fully converted — no lingering policy: / post_policy: / identity: in any example that would now hard-fail to load.
  • Keeping args: / result: as-is is the right call — renaming only the config block while the args.* / result.* attribute namespaces stay would have traded one inconsistency for another.

Would fix before merge

Fail-open: a legacy key nested inside the new authorization: block is silently dropped.
AuthorizationYaml (parser.rs) has only pre_invocation / post_invocation — no #[serde(flatten)] capture and no deny_unknown_fields. So this:

routes:
  - tool: get_employee
    authorization:
      policy:            # <- legacy key, nested under the new wrapper
        - "require(authenticated)"

deserializes with pre_invocation empty, the inner policy silently ignored, and no error — the route loads with no authorization enforced. reject_legacy_keys doesn't catch it because it inspects RouteYaml.other, but policy here was consumed as part of the authorization value, so it never lands in other.

This is a plausible partial-migration mistake: reading the migration entry policy: → authorization.pre_invocation:, it's easy to write authorization: and then habitually keep policy: underneath. It's the exact fail-open the rest of the PR guards against, just at one nesting the guard doesn't reach.

Fix: add #[serde(deny_unknown_fields)] to AuthorizationYaml (it has no flatten, so this is safe, and it also catches typos like pre_invocaton:). A regression test for authorization: { policy: [...] } → load error would lock it in.

Minor

Both forms on the same section silently merge (double-execution footgun). compile_apl_blocks does auth_pre.iter().chain(raw.pre_invocation.iter()), so if a section declares both nested authorization.pre_invocation and flat pre_invocation, the entries concatenate rather than conflict. For idempotent denies that's harmless, but a run(audit-log), a Transform/mutating plugin, or a delegate() declared in both forms runs twice. Given the PR fails loud on almost everything else, silently merging both-on-the-same-section is a little inconsistent — worth either a doc note or rejecting the both-present case. (Mixing forms across different scopes — e.g. global nested, route flat — is fine; they're meant to stack.)

Naming (non-blocking)

Overall I'm fine with the direction — identity: → authentication: is a clear win, and pre_invocation / post_invocation fixes the old policy / post_policy asymmetry cleanly. One small semantic note for the record: the pre-invocation phase isn't a binary allow/deny — it compiles to effects that include Elicit (human approval / suspend), Taint, Delegate, and Plugin (which can modify_payload), so "authorization" slightly undersells it. The nice thing is the flat pre_invocation: / post_invocation: form sidesteps that entirely (it names when, not what), so anyone who finds authorization: too narrow can just use the flat keys. Since the wrapper is optional, this isn't something to block on — just worth a sentence in the docs that the phase runs a decision plus obligations/effects, so nobody reads authorization: as a pure gate.

…thz forms

Addresses review feedback on #111:

1. Fail-open (blocker): a legacy key nested under the new wrapper
   (`authorization: { policy: [...] }`) was silently dropped — both
   phase lists parsed empty with no error, loading a route with no
   authorization enforced. `AuthorizationYaml` had no unknown-field
   guard, and the top-level `reject_legacy_keys` can't see keys consumed
   inside the `authorization` value. Add `#[serde(deny_unknown_fields)]`
   to `AuthorizationYaml` (safe — no `flatten`); it turns nested legacy
   keys and typos (`pre_invocaton:`) into load errors.

2. Double-execution footgun: declaring the same phase both nested under
   `authorization:` and flat on one section concatenated the entries,
   running effects (`run(...)`, `delegate(...)`) twice. Reject the
   both-forms-same-section case with a clear error. Cross-scope stacking
   (e.g. global nested + route flat) is unaffected.

3. Docs: note that `authorization` names *when* the phase runs and can
   carry obligations/effects (taint, delegate, plugin), not just a pure
   allow/deny gate.

Adds regression tests for the nested-legacy-key, typo, and
conflicting-forms cases.

Refs #105

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof

araujof commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in ece499a:

  1. Added #[serde(deny_unknown_fields)] to AuthorizationYaml. Nested legacy keys (authorization: { policy: ... }) and typos now fail to load. Tests added.
  2. Declaring the same phase both nested and flat on one section is now a load error. Tests added.
  3. Doc note: authorization carries effects (taint, delegate, plugin), not just allow/deny.

Suite, clippy, and rustfmt green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request framework Rust

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants