Skip to content

Plugin identity & auth: propagate principals and enable host-side token exchange #72

@mlund01

Description

@mlund01

Problem

Squadron plugins today take static credentials from the vault at boot and use them for every call. That's fine for solo/personal use, but breaks down for anyone running squadron as a service:

  • Every call to a backend looks like one shared account — no attribution
  • Static tokens can't be scoped per-mission or per-principal
  • No audit trail of "who did what" through a plugin
  • Enterprises have existing identity infra (Okta, Entra, SPIFFE, cloud IAM) and want plugins to use it

Design in one sentence

Squadron propagates a principal into every plugin call, and plugins that need a backend token ask squadron to mint one — using whatever identity source the operator has wired up.

Two kinds of principal

How the mission was launched Principal Typical token flow
Scheduler, webhook, cron (service mode) Workload identity Client credentials / JWT-bearer
Human via CLI or UI (personal mode) User identity On-behalf-of exchange (RFC 8693)

Most squadron deployments will run as services. Some users will run squadron purely as a personal automation tool. The design supports both equally — the difference is just which field on CallContext is populated.

How it works

1. Every plugin call carries a CallContext

type CallContext struct {
    Principal  Principal   // oneof: UserJWT | WorkloadToken
    MissionID  string
    TaskID     string
    RequestID  string
    Constraints map[string]string // mission-derived data-access constraints (see below)
}

Local plugins (shell, parsers, file ops) ignore it entirely.

2. Plugins that need a backend token ask the host

tok, err := host.ExchangeToken(ctx, "jira.acme.com", []string{"issues:write"})

Squadron picks the right grant based on what's in CallContext. The plugin author writes one line regardless of whether a human or a cron job is driving.

3. Plugins declare what they need in ToolInfo

RequiresIdentity: &IdentityRequirement{
  Audience: "jira.acme.com",
  Scopes:   []string{"issues:write"},
  Accepts:  []PrincipalKind{User, Workload},
}

Squadron uses this to fail fast at config load, show the trust surface in squadron plugin info, and refuse to launch a mission missing a principal the tool needs.

How operators wire it up

Because squadron is open and runs everywhere, every integration point is pluggable. There's no hardcoded IdP, no hardcoded workload-identity source, no hardcoded grant type. Squadron ships reference implementations for the common ones; anything else slots in via the same interfaces.

identity_provider {
  source = "github.com/floze/squadron-idp-oidc"
  settings = { issuer = "https://acme.okta.com" }
}

workload_identity {
  source = "github.com/floze/squadron-workload-spiffe"
  settings = { socket = "unix:///run/spire/agent.sock" }
}

The plugin-author contract doesn't change regardless of which of these the operator picks.

Mission-level data-access constraints

Beyond who is calling (Principal) and what capabilities the plugin needs (OAuth scopes), we want to constrain what slice of data a mission run can touch. Mission inputs like merchant_id should bind every applicable tool call for the lifetime of the mission, with no way for the LLM to widen them.

Naming note. "Scope" already means OAuth scope in this design (IdentityRequirement.Scopes). To avoid conflation, the data-access dimension is called constraints throughout: CallContext.Constraints, HCL constraints { ... }, ConstraintBindings. OAuth scopes name capabilities; constraints name values that filter what those capabilities can reach.

CallContext.Constraints

The Constraints field on CallContext (above) is a map[string]string populated from mission inputs that the HCL marks as data-access constraints.

HCL: constraints block on a mission

mission "merchant_report" {
  inputs = {
    merchant_id = string("Merchant", true)
  }

  constraints = {
    merchant_id = inputs.merchant_id
  }

  task "fetch" { objective = "Summarize merchant activity" }
}

Validation at config load: every key in constraints must resolve to a mission input.

Tools declare which constraint keys they consume

IdentityRequirement gains ConstraintBindings:

type IdentityRequirement struct {
    Audience           string
    Scopes             []string         // OAuth capabilities (existing)
    Accepts            []PrincipalKind
    ConstraintBindings []ConstraintBinding  // NEW: mission-input filters
}

type ConstraintBinding struct {
    Key      string  // CallContext.Constraints key, e.g. "merchant_id"
    Param    string  // tool-payload path it binds to, e.g. "filters.merchant_id"
    Required bool    // mission must supply this constraint, or the tool is unusable
}

A search_transactions tool would declare:

RequiresIdentity: &IdentityRequirement{
    Audience: "ledger.acme.com",
    Scopes:   []string{"transactions:read"},   // capability
    Accepts:  []PrincipalKind{User, Workload},
    ConstraintBindings: []ConstraintBinding{   // data filter
        {Key: "merchant_id", Param: "filters.merchant_id", Required: true},
    },
}

What the host does at call time

  1. Strips bound params from the schema shown to the LLM. The model never sees filters.merchant_id as a settable field — can't be prompt-injected to widen it.
  2. Auto-injects from CallContext.Constraints before unmarshal into the plugin payload.
  3. Rejects unbound calls. If Required = true and Constraints[Key] is empty, the tool call returns an error before reaching the plugin.
  4. Bakes constraint claims into minted tokens. When the plugin calls host.ExchangeToken(ctx, audience, scopes), the host adds CallContext.Constraints into the minted token's claims (JWT custom claims, downscoped OAuth resource indicators, etc.) so the backend can enforce too.

Two enforcement layers, made explicit

Layer Mechanism Defends against
Structural (always on) hidden schema + host-side injection + override rejection LLM hallucination, prompt injection
Cryptographic (opt-in via backend) constraint claims baked into token, backend enforces compromised plugin, code-level escape

The structural layer alone is useful — it stops honest mistakes and most attacks against the LLM. Real defense-in-depth needs the backend to participate, same caveat that already applies to the principal-based design.

Config-load validation

Squadron refuses to launch a mission if:

  • A key referenced by mission.constraints is not in inputs
  • A tool with ConstraintBindings[i].Required = true is used by the mission and mission.constraints doesn't supply that key

Same fail-fast philosophy as the existing RequiresIdentity validation.

What this means for plugin authors

Plugin type What you do
Local tool Nothing.
Backend with a static API key that works for your use case Nothing. Keep reading settings from vault.
Backend behind SSO or workload-scoped auth Declare RequiresIdentity, call host.ExchangeToken at call time.
Backend that filters by a mission-supplied dimension (tenant, merchant, region, ...) Add ConstraintBindings so the host injects the value and the LLM can't widen it.

Explicitly not doing

  • Not forcing any plugin through an IdP — opt-in per plugin, per tool
  • Not replacing vault-based static credentials — still the right answer in many cases
  • Not mandating SPIFFE, OPA, Okta, or any specific vendor — the interfaces are open
  • Not changing agent/commander logic — this is purely below the tool-call boundary

Rollout

  1. Proto change: add CallContext (including Constraints) to the plugin gRPC contract
  2. Host-side ExchangeToken service exposed to plugins; bakes Constraints into minted token claims
  3. ToolInfo.RequiresIdentity with ConstraintBindings + config-load validation; HCL constraints block on missions
  4. Reference implementations: one IdentityProvider plugin (generic OIDC), one WorkloadIdentity plugin (static JWT to start, SPIFFE next), one end-to-end demo plugin that uses both a principal and a merchant_id constraint binding

1–3 land together. 4 is the usable demo.

Questions for early users

  • Are you running squadron mostly as a service, mostly personal, or both?
  • Which backends do you hit first that need real auth (Jira, GitHub, Snowflake, internal APIs)?
  • Where would your principal come from — SSO provider, SPIFFE, cloud IAM, static JWT, something else?
  • Do you have mission-level data-access constraints (tenant/merchant/region/customer) where the LLM should never be allowed to widen the filter?
  • Is the "plugin declares what backend+scopes it needs" contract enough information for your security review process, or do you need more?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions