Skip to content

expand: ExpansionFilter hook to suppress redundant derived grants#778

Draft
leet-c1 wants to merge 1 commit into
mainfrom
lee/sdk-expansion-filter
Draft

expand: ExpansionFilter hook to suppress redundant derived grants#778
leet-c1 wants to merge 1 commit into
mainfrom
lee/sdk-expansion-filter

Conversation

@leet-c1
Copy link
Copy Markdown
Contributor

@leet-c1 leet-c1 commented Apr 21, 2026

Summary

Adds an opt-in ExpansionFilter hook on the grant expander. When installed, the filter decides per-source-grant whether a derived grant should be emitted. Default (nil filter) preserves existing behavior exactly.

  • Purely additive; no behavior change for any existing caller.
  • No proto / schema / store-interface changes.
  • Variadic options on NewExpander keep the signature backward compatible.

Why

Connectors that use a sparse/authoritative grant model (e.g. anything emitting ScopeBindingTrait) can double-represent access when they also wire GrantExpandable edges from role entitlements to action entitlements. The expander fans those edges into derived grants, and those derived grants duplicate information the sparse grants already carry.

Today the only way for such a connector to stop emitting the duplicates is either (a) remove the GrantExpandable annotations and role.Grants() emission entirely (a large refactor per connector) or (b) add an ad-hoc early-return gate in the connector's role.Grants. Neither composes; both bleed policy across the connector codebase.

This primitive lets the connector express the policy once, as a predicate:

expander := expand.NewExpander(store, graph, expand.WithExpansionFilter(
    func(ctx context.Context, sg *v2.Grant, src, desc *v2.Entitlement) bool {
        // Skip expansion when the source entitlement lives on a role resource,
        // because the connector's ScopeBinding grants authoritatively cover it.
        return src.GetResource().GetId().GetResourceType() != "role"
    },
))

The SDK intentionally encodes no semantic knowledge about ScopeBinding or any specific model — it only exposes the hook. Connectors install whatever policy they need.

Regression surface

  • NewExpander(store, graph) signature unchanged (variadic options).
  • filter field defaults nil → one added nil check per source grant on the hot path, identical behavior otherwise.
  • Full pkg/sync/expand test suite passes unchanged.

Tests

New expansion_filter_test.go covers:

  1. Baseline (no filter): N source grants → N derived grants.
  2. Filter suppressing by source resource type: N source grants → 0 derived grants.
  3. Per-principal filter: N source grants → partial emission, demonstrating the filter receives full per-grant context.

Status

Draft. Opening for discussion on:

  • Whether the right opt-in point is the expander itself (current) vs. a syncer-level option (sync.WithExpansionFilter) that plumbs through automatically. A syncer-level option would be additive on top of this change; happy to add in a follow-up or amend here.
  • Whether a connector-facing interface (ExpansionFilterProvider) on the ConnectorBuilder side would be preferable to per-caller wiring.

No proto changes, no schema changes, no c1z format changes. Net-zero risk to existing connectors that don't adopt it.

Add ExpansionFilter — an optional per-source-grant predicate on Expander
that, when it returns false, skips emission of the derived grant for that
(principal, descendant_entitlement) pair. Callers opt in via
expand.WithExpansionFilter(f); default behavior (nil filter) is unchanged.

Motivation
----------
Connectors that use a sparse/authoritative grant model — e.g. anything
emitting ScopeBindingTrait — can end up double-representing access when
they also wire GrantExpandable edges from role entitlements to action
entitlements. The expander fans those out into derived grants, and the
derived grants duplicate information the sparse grants already carry.

This hook lets a connector tell the expander "don't emit a derived grant
for this source" when it knows the same access is captured elsewhere.
The SDK intentionally encodes no semantic knowledge — it only exposes
the primitive; connectors install the policy they need.

API
---
    type ExpansionFilter func(ctx, sourceGrant, sourceEnt, descEnt *v2.Entitlement) bool
    type ExpanderOption  func(*Expander)
    func WithExpansionFilter(f ExpansionFilter) ExpanderOption
    func NewExpander(store, graph, opts ...ExpanderOption) *Expander

NewExpander stays backward compatible via variadic options.

Regression surface
------------------
Purely additive: a nil filter is a no-op on the existing fast path
(one cheap nil check per source grant). Full expand test suite passes
unchanged. No proto changes, no store interface changes, no behavior
change for any existing caller.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant