Skip to content

Latest commit

 

History

History
267 lines (210 loc) · 9.41 KB

File metadata and controls

267 lines (210 loc) · 9.41 KB

Principal-aware authorization

SchemaRouter can apply trusted RBAC/ABAC claims before capability retrieval and revalidate them at the execution boundary.

SchemaRouter is not an identity provider. Authentication, SSO, token verification, group membership, and HR directory truth stay in the host application. The host supplies a verified PrincipalContext.

Employee, manager, executive

from schemarouter import (
    AuthorizationPolicy,
    AuthorizationRule,
    PrincipalContext,
    RunConfig,
    SchemaRouter,
)

policy = AuthorizationPolicy(
    rules=(
        AuthorizationRule(
            name="employee-data",
            effect="allow",
            operation="employee_records.*",
            roles_any=("employee", "manager", "executive"),
        ),
        AuthorizationRule(
            name="manager-data",
            effect="allow",
            operation="management_metrics.*",
            roles_any=("manager", "executive"),
        ),
        AuthorizationRule(
            name="executive-data",
            effect="allow",
            operation="board_financials.*",
            roles_any=("executive",),
        ),
    )
)

router = SchemaRouter(authorization_policy=policy)

employee = PrincipalContext(subject="alice", roles=("employee",))
manager = PrincipalContext(subject="bob", roles=("manager",))
executive = PrincipalContext(subject="ceo", roles=("executive",))

When an authorization policy is configured, planning and retrieval without a principal fail closed.

employee_candidates = router.retrieve_authorized(
    "quarterly finance",
    principal=employee,
    k=5,
)

executive_candidates = router.retrieve_authorized(
    "quarterly finance",
    principal=executive,
    k=5,
)

Unauthorized endpoints are removed before ranking. This prevents a downstream model from receiving a forbidden tool merely to have execution reject it later.

Execution is checked again:

result = await router.execute(
    plan,
    config=RunConfig(principal=executive),
)

A plan created for a broader principal cannot be replayed by a narrower principal.

Department, team, and attributes

Rules can combine RBAC with ABAC:

AuthorizationRule(
    name="sales-apac-enterprise",
    effect="allow",
    operation="sales_pipeline.*",
    departments_any=("sales",),
    teams_any=("enterprise",),
    attributes=(("region", "apac"),),
)

Selectors across categories are ANDed; values inside an *_any selector are ORed. Rules are evaluated in declaration order and the first matching rule wins.

First-match precedence and policy lint

Runtime evaluation preserves declaration order: the first matching authorization or data-scope rule wins. Configuration loading therefore treats provably shadowed or duplicate matchers as a configuration error by default instead of silently accepting an unreachable later rule.

from schemarouter import parse_authorization_policy

policy = parse_authorization_policy(policy_json)  # lint=True by default

The lint step diagnoses duplicate names, duplicate match conditions, and broad-before-narrow cases that can be proven unreachable without attempting unsafe inference over arbitrary wildcard predicates. It reports rule names/positions only to the trusted configuration surface; runtime authorization denial messages remain intentionally generic. Set lint=False only for an explicit backward-compatibility migration where the host has independently reviewed the ordering.

Compiled lookup partitions may skip unrelated rules for performance, but they always preserve the original declaration order and first-match result.

Default behavior

AuthorizationPolicy is deny-by-default. If no rule matches, the route is invisible and cannot execute.

A router created without authorization_policy preserves the existing no-principal behavior.

Non-disclosure boundary

Authorization is applied as an additional local availability predicate during planning/retrieval. Invisible endpoints are not returned as rejected candidates. The existing capability-eligibility API similarly returns no explanation for host-invisible capabilities.

Authorization failures at the execution boundary use a generic denial message rather than exposing which role, department, team, or attribute failed.

Database authorization

Database onboarding builds on this principal boundary. Database adapters should expose only the table/column schema permitted for the selected access scope and must enforce row predicates inside the trusted database invoker. Model-supplied arguments must never be able to remove or weaken those row predicates.

Field, row, tenant, and traversal scopes

Capability authorization answers whether a principal may use an endpoint. Data-scope rules narrow what that already-authorized endpoint may expose or execute.

from schemarouter import DataScopeRule, TrustedFilterBinding

policy = AuthorizationPolicy(
    rules=(
        AuthorizationRule(
            effect="allow",
            operation="company.employees.select",
            roles_any=("employee", "manager", "executive"),
        ),
    ),
    data_rules=(
        DataScopeRule(
            name="employee-row-scope",
            operation="company.employees.select",
            roles_any=("employee",),
            visible_fields=("id", "name"),
            trusted_filters=(
                TrustedFilterBinding(
                    field="department",
                    principal_value="attribute:department",
                ),
            ),
        ),
        DataScopeRule(
            name="manager-row-scope",
            operation="company.employees.select",
            roles_any=("manager",),
            visible_fields=("id", "name", "department", "salary"),
            trusted_filters=(
                TrustedFilterBinding(
                    field="department",
                    principal_value="attribute:department",
                ),
            ),
        ),
        DataScopeRule(
            name="executive-scope",
            operation="company.employees.select",
            roles_any=("executive",),
            visible_fields=("id", "name", "department", "salary"),
        ),
    ),
)

For the employee rule above:

  • salary and department are removed from the model-visible retrieval schema before scoring;
  • the trusted department predicate is resolved from the verified principal context;
  • the predicate is injected inside the trusted database invoker and cannot be removed by model arguments;
  • a forged/stale plan that explicitly asks for a hidden field fails closed at execution.

TrustedFilterBinding can source values from subject, role, department, team, or attribute:<name>. Missing required attributes fail closed.

The same scope contract is reused across database families:

Data family Scope enforcement
SQLite / SQLAlchemy RDB visible columns + trusted equality/IN row predicates
Vector stores visible result/metadata fields + trusted filterable metadata predicates
Document/search/KV/time-series visible fields + trusted exact-match predicates
Graph / RDF visible result fields + allowed relationship/predicate set + maximum hop depth

For vector stores, a backend must explicitly support a trusted filters argument before a configured tenant filter can execute. If it cannot enforce the filter, SchemaRouter denies the call rather than silently running an unscoped search.

For graph/RDF stores, omitting relationship_types does not widen access: the invoker defaults to the principal's allowed relationship set. A configured max_hops also caps the implicit default.

Security boundary

Data-scope rules only narrow the application-visible surface. They never grant database privileges. Database-native roles, grants, row-level security, ACLs, tenant credentials, and network boundaries remain authoritative and should be configured independently.

The first matching data-scope rule applies, mirroring capability-rule ordering. Keep broad rules after specific employee/team/department rules.

Authorization audit events

Enterprise hosts can observe authorization decisions through an opt-in trusted callback without persisting raw principal claims or trusted-filter values.

from schemarouter import RunConfig, SchemaRouter

audit_events = []

router = SchemaRouter(
    authorization_policy=policy,
    authorization_audit_hook=audit_events.append,
)

result = await router.execute(
    plan,
    config=RunConfig(
        principal=employee,
        principal_audit_id="directory-user-7f3a",
    ),
)

Each AuthorizationAuditEvent contains the decision effect, rule/default source, matched rule name, data-scope rule name, visible-field count, trusted-filter field names, graph scope summary, tool/endpoint identity, phase, and a run ID. When a host supplies principal_audit_id, the same opaque identifier is carried into the event.

The runtime creates a run ID when the host does not provide one. LangChain and LlamaIndex exports reuse the same run ID for the export decision and the later execution decision, so a trusted audit sink can correlate the boundary without receiving the raw PrincipalContext.

By default the audit hook is disabled. SchemaRouter does not automatically persist subjects, roles, departments, teams, principal attributes, or resolved trusted-filter values. If a host chooses to copy additional identity data into its own audit sink, that sink becomes part of the host's trusted security boundary.