Authentication & Authorization

in Constructor Fabric Gears (Rust)

Vendor-agnostic identity & access · query-level enforcement · compile-time safety

By the Cyber Fabric Foundation · Apache-2.0

June 23, 2026

Agenda

  • The premise — Gears is a vendor-agnostic framework
  • Foundations — tenant model & resource groups
  • Abstraction — plugins, separate AuthN / AuthZ resolvers
  • Authentication — AuthN Resolver → SecurityContext
  • Authorization — coarse- vs fine-grained · PDP / PEP / PIP
  • The fine-grained model — AuthZEN + query-level constraints
  • Mechanics — predicate types, projection tables
  • In codeSecurityContextPolicyEnforcerAccessScope → repo
  • Platform-plane auth
  • Roadmap · Takeaways

The premise — Gears is vendor-agnostic

Gears is batteries-included — many built-in gears you compose into a platform out of the box: tenancy, auth, events, and more.

  • The killer feature: that same system can also slot into a vendor's existing platform
  • That vendor almost always already has identity and access management
  • Their stack varies — OIDC/JWT or opaque tokens · RBAC, ABAC, ReBAC, custom DSLs — Gears assumes none of it

So Gears defines the contracts; the vendor supplies the behavior.
No policy-language lock-in, and resources never leave the gear's own database.

Tenant model & tenant isolation

A tenant is the boundary of ownership and isolation.

  • Every resource must belong to exactly one tenant
  • Single-root tree topology: one root; every other tenant has one parent
  • Isolation by default — no cross-tenant access; a parent may reach its children
  • Barriers — a self_managed tenant hides its subtree from the parent
    (business data is hidden, but billing / usage still rolls up)

Resource Group Model

An optional layer for grouping resources, so access can be granted at the group level
instead of per-resource — and inherited down the hierarchy.

Tenant Resource Group
Purpose ownership, isolation, billing grouping for access control
Hierarchy single-root tree forest (roots per tenant)
Relationship ownership (1:N) membership (M:N)

Always tenant-scoped: cross-tenant groups are forbidden, and the tenant predicate always applies alongside the group predicate.

Plugin — the main abstraction mechanism

A plugin is how Gears stays vendor-neutral — the platform's open-closed extension point.

  • A host gear publishes a plugin interface (a trait) in its SDK and registers its schema in the Types Registry
  • A plugin gear implements that interface and registers as a scoped ClientHub client, keyed by GTS instance ID
  • The host discovers plugins at runtime and routes to the one a vendor config field selects
  • Built-in → compiled in-process · External → separate deployable over gRPC

Add a new backend (e.g. a custom auth provider) = write a plugin
the host gear and all its consumers stay unchanged.

Two plugins: AuthN Resolver & AuthZ Resolver

Each half of the story is just a plugin:

  • Authentication → an AuthN Resolver Plugin
  • Authorization → an AuthZ Resolver Plugin
AuthN Resolver AuthZ Resolver
Standard OIDC / JWT OpenID AuthZEN
Answers Who is the caller? What may they touch?
Output SecurityContext decision + constraints

Kept separate on purpose — different standards & caching, credentials isolated in AuthN.
Mix & match: a standard IdP (Auth0, Okta) + a custom policy engine (OpenFGA, Oso).

Authentication → SecurityContext

The AuthN Resolver plugin validates the token and produces a SecurityContext:

  • subject_id · subject_type · subject_tenant_idwho, and their home tenant
  • token_scopes — capability ceiling from the token (first-party ["*"] vs scoped third-party)
  • bearer_token — secret-wrapped, never logged; forwarded to the PDP, or reused on the gear's own out-of-process calls

The plugin owns: token validation, claim extraction & enrichment, scope detection.

Gear code never parses tokens or resolves tenancy directly — it must stay
token-format agnostic, so that is the AuthN Resolver's job.

The AuthN Resolver interface

A vendor plugin implements one small trait — that's the entire integration surface:

#[async_trait]
pub trait AuthNResolverPluginClient: Send + Sync {
    /// Validate a bearer token → identity.
    async fn authenticate(
        &self,
        bearer_token: &str,
    ) -> Result<AuthenticationResult, AuthNResolverError>;

    /// Service-to-service: OAuth2 client-credentials → identity.
    async fn exchange_client_credentials(
        &self,
        request: &ClientCredentialsRequest,
    ) -> Result<AuthenticationResult, AuthNResolverError>;
}

AuthenticationResult carries the SecurityContext. Any IdP, any token format —
JWT, opaque + introspection, PASETO — lives behind these two methods.

Authorization

Two grains — coarse and fine

Authorization comes in two grains

Coarse-grained Fine-grained
Asks can this app hit this API at all? which rows may this subject touch?
Unit OAuth token scopes permissions → constraints
Where API Gateway (system middleware) — early reject the gear's domain layer

Coarse-grained is a fast app-level ceiling; fine-grained is the row-level truth —
and it's where the rest of this talk lives.

Coarse-grained access control

A fast capability gate that runs before any policy evaluation:

  • Token scopes — a per-application ceiling, set by the AuthN plugin
    (first-party UI/CLI → ["*"] · third-party → ["read:events", …])
  • API Gateway route policies — reject on scope mismatch before any fine-grained evaluation
  • Coarse, app-level, human-readable — not per-resource
api-gateway:
  route_policies:
    rules:
      - path: "/events/v1/*"
        required_scopes: ["read:events", "write:events"]  # any of these

A capability ceiling and a fast-path optimization — the per-row, fine-grained checks still run later.

Fine-grained authorization — the vocabulary

Built on the NIST SP 800-162 PDP / PEP model:

Role Responsibility In Gears
PDP — Policy Decision Point evaluate policy → decision + constraints AuthZ Resolver plugin (vendor)
PEP — Policy Enforcement Point apply the decision at data access the domain gear
PIP — Policy Information Point supply attributes for the decision Account Management gear (tenants) · Resource Group gear (membership & hierarchy) · or vendor-side
PAP — Policy Administration Point author & manage policies Access Management gear (planned) · or vendor-side

Existing solutions & standards

The industry already offers building blocks — we evaluated each:

Option Why not
AuthZEN (as-is) a point-check API — no constraints, so LIST needs iterative fetch/filter
Zanzibar / ReBAC O(N) checks and resource sync into the policy store
OPA partial eval policies must be Rego → policy-language lock-in
Gear-level auth scatters policy across gears; no PDP/PEP separation

Chosen — AuthZEN + constraint extensions: standards-based and vendor-neutral, with one
targeted addition — typed predicates — that the next slides unpack.

AuthZEN — the standard we build on

OpenID AuthZEN is the OpenID Foundation's authorization API standard — and our PDP/PEP contract.

  • Standard & vendor-neutral — one request shape across PDPs; growing ecosystem with interop tests
  • Simple & transport-agnostic — Access Evaluation API: subject + action + resource + context → decision
  • Clean PDP / PEP split — swap the policy engine without touching gear code
  • Extensiblecontext is the official extension point, exactly where we add constraints

We take the standard as-is and add one thing — context.constraints for query-level enforcement.
github.com/openid/authzen

The problem — point checks aren't enough

AuthZEN's evaluation API answers "can subject S do action A on resource R?".
That alone breaks down for real CRUD:

  • LIST → fetch a batch, send to PDP, filter, repeat… cursors invalidate, counts are wrong,
    worst case scans the whole table to return an empty page
  • GET / UPDATE / DELETE → fetch-then-check wastes a query and opens a TOCTOU gap

We need authorization at the query level — as SQL WHERE clauses —
not just point-in-time yes/no decisions.

The AuthZ Resolver interface

The PDP side is just as small — one method the vendor plugin implements:

#[async_trait]
pub trait AuthZResolverPluginClient: Send + Sync {
    /// Evaluate an authorization request → decision + constraints.
    async fn evaluate(
        &self,
        request: EvaluationRequest,
    ) -> Result<EvaluationResponse, AuthZResolverError>;
}

evaluate takes an EvaluationRequest and returns an EvaluationResponse
(decision + constraints) — both AuthZEN-shaped. The next two slides show each.

Evaluation request

get_chat reads one chat → the EvaluationRequest the gear passes to evaluate(...):

{
  "subject":  { "id": "user-uuid",
                "properties": { "tenant_id": "tenant-uuid" } },
  "action":   { "name": "read" },
  "resource": {
    "type": "gts.cf.core.ai_chat.chat.v1~cf.core.mini_chat.chat.v1~",
    "id": "chat-uuid"
  },
  "context":  {
    "require_constraints": true,
    "supported_properties": ["owner_tenant_id", "owner_id", "id"]
    // "capabilities": [...] — unlock subtree / group predicates:
    //   "tenant_hierarchy" · "group_membership" · "group_hierarchy"
  }
}

Evaluation response — AuthZEN + constraints

The PDP replies with a decision plus optional context.constraints — typed
predicates the PEP compiles straight to SQL.

{ "decision": true,
  "context": { "constraints": [ { "predicates": [
    { "type": "eq", "resource_property": "owner_tenant_id",
      "value": "tenant-uuid" },
    { "type": "eq", "resource_property": "owner_id",
      "value": "user-uuid" }
  ] } ] } }
  • Predicates within a constraint are AND-ed — this tenant and owned by me
  • No resource sync — the PDP returns predicates, never resource IDs · O(1) per query

Capabilities — what the PEP can enforce

The PEP declares which predicate types it can run locally; the PDP returns only those.

Capability Enables Local table
(always on) eq, in
tenant_hierarchy in_tenant_subtree tenant_closure
group_membership in_group resource_group_membership
group_hierarchy in_group_subtree resource_group_closure + membership

Degradation — lacking a capability, the PDP expands to explicit in IDs, or denies.
A gear with no projections still works; it just gets simpler predicates.

Predicate types

The PEP declares which it supports; the PDP returns only those.

Type Meaning Compiles to
eq property equals value col = ?
in property in set col IN (?, …)
in_tenant_subtree within a tenant subtree join tenant_closure (barrier-aware)
in_group direct group membership join resource_group_membership
in_group_subtree group + descendants join group closure + membership

Predicates name properties, not columns. Unknown type → fail-closed.
Extensible — vendors can register custom predicate types.

Projection tables — strategy

Subtree/group predicates need local hierarchy data (tenant_closure,
resource_group_membership, group closure) so the PEP can JOIN at query time.

  • Monolith / shared DB — co-located canonical tables, no projection needed
  • Microservices — default to PDP-resolved in predicates (two-request pattern)
  • Project only after profiling — membership tables can be ~10× the hierarchy size

Replication of these projection tables is in-design — keeping local copies in sync
across services is the open work item.

Permissions — what can be granted

A permission = { resource_type, action }, declared by each gear as a GTS instance of
gts.cf.toolkit.authz.permission.v1~:

{ "id": "gts.cf.toolkit.authz.permission.v1~cf.mini_chat._.chat_read.v1",
  "resource_type": "gts.cf.core.ai_chat.chat.v1~cf.core.mini_chat.chat.v1~",
  "action": "read",
  "display_name": "Read chat" }
  • resource_type — a concrete type, a wildcard, or an ABAC query (…[category='support'])
  • action — one concrete verb; the catalog is discoverable by admin UIs / the Access Management gear

Scopes and permissions compose: effective_access = min(token_scopes, user_permissions)

Request lifecycle

Authorization in code

SecurityContextPolicyEnforcerAccessScope → repository

Domain layer — the read path (get_chat)

pub async fn get_chat(&self, ctx: &SecurityContext, id: Uuid)
    -> Result<ChatDetail, DomainError>
{
    let conn = self.db.conn()?;

    // PEP: ask the PDP what this subject may read
    let chat_scope = self.enforcer
        .access_scope(ctx, &resources::CHAT, actions::READ, Some(id))
        .await?                            // → AccessScope (row-level constraints)
        .ensure_owner(ctx.subject_id());   // defense-in-depth

    // the scope flows into the repository as the WHERE clause
    let chat = self.chat_repo.get(&conn, &chat_scope, id).await?
        .ok_or_else(|| DomainError::chat_not_found(id))?;
    Ok(/* … */)
}

Domain layer — the write path (create_chat)

let scope = self.enforcer
    .access_scope_with(
        ctx, &resources::CHAT, actions::CREATE, None,
        &AccessRequest::new()                        // declare the proposed owner
            .resource_property(pep_properties::OWNER_TENANT_ID, tenant_id)
            .resource_property(pep_properties::OWNER_ID, ctx.subject_id()),
    )
    .await?;

let created = self.chat_repo.create(&conn, &scope, chat).await?;

For CREATE there is no row yet — the gear sends the proposed owner properties so the
PDP can authorize the insert. Same AccessScope, same repository contract.

Infra layer — secure by construction

Entity opts into scoping; the macro forces an explicit column mapping:

#[derive(DeriveEntityModel, Scopable)]
#[sea_orm(table_name = "chats")]
#[secure(tenant_col = "tenant_id", owner_col = "user_id",
         resource_col = "id", no_type)]
pub struct Model { /* id, tenant_id, user_id, model, … */ }

Repository turns the AccessScope into a WHERE clause:

Entity::find()
    .filter(/* id = ? AND deleted_at IS NULL */)
    .secure()            // opt into scoped query
    .scope_with(scope)   // ← AccessScope becomes the WHERE
    .one(conn).await?

Forget the scope? It won't compile

.secure() returns an Unscoped query — the execution methods exist only once it is scoped:

Entity::find()
    .secure()            // SecureSelect<Entity, Unscoped>
    .all(conn).await     // ← forgot .scope_with(scope)
error[E0599]: no method named `all` found for struct
  `SecureSelect<Entity, Unscoped>` in the current scope
   = note: the method was found for `SecureSelect<E, Scoped>`

A typestate, not a lint — an unscoped query has no .all() / .one() to call.
A maintained trybuild test keeps this guarantee from regressing.

The type system enforces tenancy

Forget to decide the tenant mapping — it does not compile:

#[derive(Scopable)]
#[secure(
    resource_col = "id",
    no_owner,
    no_type,
)]                       // ← no tenant_col and no no_tenant
struct Model;
error: secure: missing explicit decision for tenant:
         use `tenant_col = "column_name"` or `no_tenant`
 --> src/infra/db/entity/chat.rs

Security isn't a convention you can forget — an unscoped entity is a build failure.

Platform-plane authentication (out-of-process)

When gears call each other outside a user request (registration, heartbeats, jobs),
how does a gear prove its own identity?

  • Phase 1 (now) — K8s ServiceAccount tokens (TokenReview) · bootstrap token over UDS
  • End statemTLS + SPIFFE workload certs (spiffe://…/gear/<gear>/<version>)
  • One abstraction across phases: InternalCredential + PlatformIdentity
  • Distinct header X-ToolKit-Internal-Token — never collides with the user's Authorization

Tenant-plane (user JWT) and platform-plane (gear identity) are separate trust planes.

Not done yet — roadmap

Being honest about the gaps; each has a clear path:

  • Batch evaluation optimization — one evaluate call for many resources at once
  • Local projections sync — keep tenant_closure & membership replicas in step
  • Authorization decision caching — cache PDP decisions + constraints (TTL-bounded)
  • Multi-Factor Authentication (MFA) — step-up / assurance-level awareness in AuthN
  • S2S SecurityContext caching — reuse client-credentials identities
  • More dylint rules — widen compile-time architecture enforcement
  • Access Management built-in gear — policy administration (PAP) + a default PDP

Mostly performance, coverage, and tooling — the core model is already in place.

Takeaways

  1. Vendor-agnostic by design — host gears define contracts; vendor plugins supply
    authn & authz. No policy lock-in, no resource sync.
  2. Query-level enforcement — the PDP returns predicates, the PEP compiles them to SQL.
    Correct LIST, pagination, counts, and TOCTOU safety — at O(1) overhead.
  3. Compile-time safetySecurityContextPolicyEnforcerAccessScope → scoped
    repo, and an unscoped entity simply won't build.

One coherent security data-path — with no unscoped shortcut to reach for.

Questions?

Authentication & Authorization in Constructor Fabric Gears

Vendor-agnostic · Query-level enforcement · Compile-time safe

docs/arch/authorization/ · docs/arch/toolkit-oop/