diff --git a/agent-templates/a2a/authz.py b/agent-templates/a2a/authz.py index c1a5ca8..467c938 100644 --- a/agent-templates/a2a/authz.py +++ b/agent-templates/a2a/authz.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from enum import Enum +from .delegation import Delegation, DelegationPolicy, SkillClass + _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _EXEC_PRINCIPAL_RE = re.compile(r"^Exec-[a-z0-9_-]+$") @@ -37,6 +39,10 @@ class AuthContext: caller: str scopes: frozenset[str] = frozenset() authenticated: bool = True + #: `delegated-principal` extension claims, from an ALREADY-VERIFIED credential. + #: None = the caller sent none, which is exactly v1 and is only a problem for a + #: skill the callee classified `principal-required`. + delegation: Delegation | None = None @dataclass(frozen=True) @@ -77,6 +83,9 @@ def authorize( *, skill_role: dict | None = None, skill_known: bool = True, + skill_key: str | None = None, + delegation_policy: DelegationPolicy | None = None, + permit_check=None, ) -> AuthzResult: """Run the callee-side decision procedure (authz.md §3). @@ -87,6 +96,12 @@ def authorize( 4. providesTo: ABSENT -> DENY, [] -> DENY, caller not in -> DENY. 5. skill: unknown/unpublished-to-caller -> DENY; a2a.scopes present but token lacks them -> SCOPE_REQUIRED (the sole legitimate AUTH_REQUIRED). + 6. delegated-principal extension, ONLY when ``delegation_policy`` is given. + Absent policy == the callee did not adopt the extension == exact v1. + + Step 6 runs AFTER step 4 on purpose: channel authz still decides first, so a + caller outside ``providesTo`` is denied before any principal is consulted. The + extension narrows; it never widens. """ if not ctx.authenticated or not valid_caller_identity(ctx.caller): return AuthzResult(Decision.DENY, "unauthenticated or invalid caller identity") @@ -118,4 +133,85 @@ def authorize( missing_scopes=missing, ) + if delegation_policy is not None: + return _authorize_delegated(ctx, delegation_policy, skill_key, permit_check) + return AuthzResult(Decision.ALLOW, "authorized") + + +def _authorize_delegated( + ctx: AuthContext, + policy: DelegationPolicy, + skill_key: str | None, + permit_check, +) -> AuthzResult: + """Extension step 6 (ext/delegated-principal/v1 spec.md §§3-5). + + Every branch that cannot be evaluated is a DENY. There is no fail-open mode + and no flag that creates one: `DECISION_UNAVAILABLE` is never an allow. + """ + if not skill_key: + return AuthzResult( + Decision.DENY, + "delegation policy present but no skill key to classify -> fail closed", + ) + + klass = policy.classify(skill_key) + if klass is None: + # spec.md §3: the closed set has no fourth bucket. Defaulting an + # unclassified skill to `delegable` would let a new skill acquire the + # weakest rule in the system by being written rather than decided. + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is unclassified in delegation.skills -> fail closed", + ) + + if klass is SkillClass.NEVER_DELEGABLE: + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is never-delegable; no principal reaches it via A2A", + ) + + if klass is SkillClass.DELEGABLE: + return AuthzResult(Decision.ALLOW, "authorized (delegable)") + + # principal-required: BOTH terms of the intersection must allow (spec.md §4). + delegation = ctx.delegation + if delegation is None: + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is principal-required but the call carries no " + f"verified subject", + ) + + # Term 2 first, because it needs no network call: the subject cannot reach past + # what the agent may broker, however privileged the subject is. + if not policy.brokerable_by(delegation.actor_chain, skill_key): + return AuthzResult( + Decision.DENY, + f"no actor in {delegation.actor_chain} may broker {skill_key!r}", + ) + + # Term 1: what the SUBJECT may do, decided by Permit via FuzeFront's Security + # API. A product never calls Permit directly. + if permit_check is None: + return AuthzResult( + Decision.DENY, + "principal-required skill but no authz client is wired -> cannot " + "evaluate the subject's permission -> fail closed", + ) + try: + permitted = permit_check(delegation.subject, skill_key) + except Exception as exc: + # DECISION_UNAVAILABLE is a DENY. This is the branch that a fail-open + # implementation would turn into an ALLOW, so it is spelled out. + return AuthzResult(Decision.DENY, f"authz decision unavailable: {exc}") + if not permitted: + return AuthzResult( + Decision.DENY, + f"subject {delegation.subject!r} is not permitted {skill_key!r}", + ) + + # The agent cannot grant more than the subject has, AND the subject cannot + # reach past what the agent may broker. Both were required; neither alone. + return AuthzResult(Decision.ALLOW, "authorized (principal-required, intersection)") diff --git a/agent-templates/a2a/delegation.py b/agent-templates/a2a/delegation.py new file mode 100644 index 0000000..d367c94 --- /dev/null +++ b/agent-templates/a2a/delegation.py @@ -0,0 +1,179 @@ +"""The `delegated-principal` A2A extension (contracts/a2a/ext/delegated-principal/v1). + +WHY THIS EXISTS. v1's `AuthContext.caller` is one string, and a caller identity is +a bare repo name or an `Exec-*` principal (authz.md §2). There is no end-user +subject anywhere in the model, so a CEO and a warehouse worker arriving through +the same calling repo are indistinguishable to the callee: if `FuzeExecutive` is +in `FuzePlan`'s `providesTo`, both pass identically. + +THE POD IS A DELEGATE, NEVER A PRINCIPAL. It holds no standing authority over +product data; its workload credential authorizes exactly one thing — presenting +delegated tokens. Every bit of data-plane authority arrives with the call. The +alternative, giving the pod broad rights and having it check the caller first, is +the confused deputy: the guard becomes a matter of remembering to look. + +This module is ADDITIVE. `Delegation` absent from an AuthContext means the callee +did not adopt the extension, and `authorize()` behaves exactly as v1. Nothing +here can make a v1 deployment stricter or looser than it was. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum + +EXTENSION_URI = "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1" + +_SUBJECT_RE = re.compile(r"^(user|service):[^\s:]+$") +_ACTOR_RE = re.compile(r"^(repo|agent|service):[^\s:]+$") + + +class SkillClass(str, Enum): + """Closed. There is deliberately no fourth member and no default. + + An unclassified skill is DENIED, not treated as `DELEGABLE`. A default would + mean a newly written skill acquires the weakest rule in the system by being + written rather than by being decided — the same closed-set property the route + and OpenAPI gates enforce. + """ + + DELEGABLE = "delegable" + PRINCIPAL_REQUIRED = "principal-required" + NEVER_DELEGABLE = "never-delegable" + + +class DelegationError(ValueError): + """A malformed policy. Raised at load, never carried into a decision.""" + + +@dataclass(frozen=True) +class Delegation: + """Claims read from an ALREADY-VERIFIED credential. + + Constructing this from an unverified token is the one way to misuse the + module: an unverified `sub` is worth less than no `sub`, because it looks + like authority. The caller of `parse_claims` verifies signature and `aud` + first — this module never sees a raw token and cannot check that for you. + """ + + #: RFC 8693 `sub` — the ORIGINATING principal, whom the work is ultimately for. + subject: str + #: RFC 8693 `act` chain, outermost first: immediate actor, then its actor. + actor_chain: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class DelegationPolicy: + """A callee's classification of its own skills, plus who may broker what. + + Loaded from `.fuze/a2a-delegation.json` — see `from_manifest` for why it is a + separate file rather than a manifest key. + """ + + skills: dict[str, SkillClass] + brokerable: dict[str, frozenset[str]] + authz_base_url: str | None = None + + @classmethod + def from_manifest(cls, block: dict | None) -> "DelegationPolicy | None": + """Build from `.fuze/a2a-delegation.json`, or None when the file is absent. + + Its OWN file, not a key in the manifest's `a2a` block: v1's + manifest-a2a-extension.schema.json sets additionalProperties:false there, + so a `delegation` key would make every adopting repo's manifest fail v1 + validation. An extension you must edit the frozen contract to adopt is a + version, not an extension. + + None means "this callee did not adopt the extension" and restores exact v1 + behaviour. A block that is PRESENT but malformed raises: half-configured + delegation is more dangerous than none, because it reads as protection. + """ + if not block: + return None + if block.get("extension") != EXTENSION_URI: + raise DelegationError( + f"delegation block does not name {EXTENSION_URI}; refusing to guess " + f"which extension's rules apply" + ) + raw_skills = block.get("skills") + if not isinstance(raw_skills, dict) or not raw_skills: + raise DelegationError("delegation.skills must be a non-empty object") + skills = {} + for key, value in raw_skills.items(): + try: + skills[key] = SkillClass(value) + except ValueError as exc: + raise DelegationError( + f"skill {key!r} has unknown class {value!r}; the classes are " + f"{[c.value for c in SkillClass]} and there is no default" + ) from exc + raw_brokerable = block.get("brokerable") + if not isinstance(raw_brokerable, dict): + raise DelegationError("delegation.brokerable must be an object") + brokerable = {} + for actor, roles in raw_brokerable.items(): + if not _ACTOR_RE.match(actor): + raise DelegationError( + f"brokerable key {actor!r} is not a typed actor reference " + f"(repo:/agent:/service:)" + ) + if not isinstance(roles, list): + raise DelegationError(f"brokerable[{actor!r}] must be an array") + brokerable[actor] = frozenset(roles) + return cls( + skills=skills, + brokerable=brokerable, + authz_base_url=block.get("authzBaseUrl"), + ) + + def classify(self, skill: str) -> SkillClass | None: + """The skill's class, or None when unclassified. None is DENY, not a default.""" + return self.skills.get(skill) + + def brokerable_by(self, actor_chain: tuple[str, ...], skill: str) -> bool: + """May any actor in the chain broker this skill? + + An actor absent from `brokerable` brokers NOTHING — absence is not a + wildcard. An explicit empty list says the same thing on purpose, which is + a meaningful statement in a way absence is not. + """ + return any( + skill in self.brokerable.get(actor, frozenset()) for actor in actor_chain + ) + + +def parse_claims(claims: dict | None) -> Delegation | None: + """Delegation from VERIFIED credential claims, or None when absent. + + Raises DelegationError on a malformed chain rather than dropping the bad + entry: an actor reference that cannot be evaluated must not be treated as + satisfied, and silently ignoring it is exactly that. + """ + if not claims: + return None + subject = claims.get("sub") + if not subject: + return None + if not _SUBJECT_RE.match(subject): + raise DelegationError( + f"sub {subject!r} is not a typed principal (user:/service:); an untyped " + f"id cannot be resolved to a principal kind and so cannot be checked" + ) + chain: list[str] = [] + node = claims.get("act") + while isinstance(node, dict): + actor = node.get("sub") + if not actor or not _ACTOR_RE.match(actor or ""): + raise DelegationError( + f"actor {actor!r} is not a typed reference (repo:/agent:/service:); " + f"it cannot be matched against the brokerable set" + ) + chain.append(actor) + node = node.get("act") + if not chain: + raise DelegationError( + "sub is present but the act chain is empty; a delegated call must name " + "the actor doing the delegating" + ) + return Delegation(subject=subject, actor_chain=tuple(chain)) diff --git a/agent-templates/a2a/tests/test_delegation.py b/agent-templates/a2a/tests/test_delegation.py new file mode 100644 index 0000000..419ccc7 --- /dev/null +++ b/agent-templates/a2a/tests/test_delegation.py @@ -0,0 +1,222 @@ +"""Tests for the delegated-principal extension. + +The property under test is mostly that things are DENIED. An authz test suite +that only proves "the allowed call is allowed" proves nothing an unconditional +`return ALLOW` would not also pass. + +Two of these are the reason the extension exists at all: + * test_ceo_and_worker_are_distinguishable — under v1 they are not. + * test_intersection_not_union — the load-bearing rule; a union is not a weaker + version of it, it is the opposite of it. +""" + +import unittest + +from a2a.authz import AuthContext, Decision, authorize +from a2a.delegation import ( + EXTENSION_URI, + Delegation, + DelegationError, + DelegationPolicy, + SkillClass, + parse_claims, +) + +MANIFEST = {"providesTo": ["FuzeExecutive", "FuzeSales"]} + +POLICY_BLOCK = { + "extension": EXTENSION_URI, + "authzBaseUrl": "http://fuzefront-backend:3001", + "skills": { + "plan-reader": "delegable", + "plan-editor": "principal-required", + "plan-purge": "never-delegable", + }, + "brokerable": { + "repo:FuzeExecutive": ["plan-reader", "plan-editor"], + "repo:FuzeSales": ["plan-reader"], + }, +} + +CEO_CLAIMS = { + "sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}, +} +WORKER_CLAIMS = { + "sub": "user:worker@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}, +} + + +def policy(): + return DelegationPolicy.from_manifest(POLICY_BLOCK) + + +def ctx(claims=None, caller="FuzeExecutive"): + return AuthContext(caller=caller, delegation=parse_claims(claims)) + + +def decide(claims=None, skill="plan-editor", permit=None, caller="FuzeExecutive"): + return authorize( + ctx(claims, caller), + MANIFEST, + skill_key=skill, + delegation_policy=policy(), + permit_check=permit, + ) + + +class BackwardCompatibilityTests(unittest.TestCase): + """No policy == the callee never adopted the extension == exact v1.""" + + def test_without_a_policy_v1_behaviour_is_untouched(self): + r = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + self.assertIs(r.decision, Decision.ALLOW) + + def test_a_caller_outside_providesto_is_denied_before_any_principal(self): + """Channel authz still decides first; the extension narrows, never widens.""" + r = decide(CEO_CLAIMS, caller="FuzeSocial", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("providesTo", r.reason) + + +class ClosedClassificationTests(unittest.TestCase): + def test_an_unclassified_skill_is_denied_not_defaulted_to_delegable(self): + """spec.md §3: no fourth bucket, and no default.""" + r = decide(CEO_CLAIMS, skill="plan-undeclared", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("unclassified", r.reason) + + def test_never_delegable_denies_even_a_fully_permitted_subject(self): + r = decide(CEO_CLAIMS, skill="plan-purge", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("never-delegable", r.reason) + + def test_delegable_needs_no_principal(self): + r = decide(None, skill="plan-reader") + self.assertIs(r.decision, Decision.ALLOW) + + def test_an_unknown_class_in_the_policy_is_a_load_error(self): + bad = dict(POLICY_BLOCK, skills={"x": "maybe"}) + with self.assertRaises(DelegationError): + DelegationPolicy.from_manifest(bad) + + def test_a_policy_naming_no_extension_is_refused(self): + bad = dict(POLICY_BLOCK) + del bad["extension"] + with self.assertRaises(DelegationError): + DelegationPolicy.from_manifest(bad) + + +class PrincipalTests(unittest.TestCase): + def test_ceo_and_worker_are_distinguishable(self): + """THE gap. Under v1 both arrive as caller='FuzeExecutive' and both pass.""" + v1_ceo = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + v1_worker = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + self.assertEqual(v1_ceo.decision, v1_worker.decision) # indistinguishable + + permit = lambda sub, _skill: sub == "user:ceo@fuzefront.com" + self.assertIs(decide(CEO_CLAIMS, permit=permit).decision, Decision.ALLOW) + self.assertIs(decide(WORKER_CLAIMS, permit=permit).decision, Decision.DENY) + + def test_principal_required_without_a_subject_is_denied(self): + r = decide(None, permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("no verified subject", r.reason) + + +class IntersectionTests(unittest.TestCase): + """effective = permitted(sub) ∩ brokerable(actor). Both, or neither counts.""" + + def test_intersection_not_union(self): + allow_all = lambda *_: True + deny_all = lambda *_: False + + # Subject permitted, actor may broker -> ALLOW. + self.assertIs(decide(CEO_CLAIMS, permit=allow_all).decision, Decision.ALLOW) + + # Subject permitted, actor may NOT broker -> DENY. A union would allow. + sales_claims = { + "sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeSales"}, + } + r = authorize( + ctx(sales_claims, caller="FuzeSales"), + MANIFEST, + skill_key="plan-editor", + delegation_policy=policy(), + permit_check=allow_all, + ) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("broker", r.reason) + + # Actor may broker, subject NOT permitted -> DENY. A union would allow. + r = decide(CEO_CLAIMS, permit=deny_all) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("not permitted", r.reason) + + def test_an_actor_absent_from_brokerable_brokers_nothing(self): + """Absence is not a wildcard.""" + claims = {"sub": "user:ceo@fuzefront.com", "act": {"sub": "repo:FuzeUnknown"}} + r = authorize( + ctx(claims), + MANIFEST, + skill_key="plan-editor", + delegation_policy=policy(), + permit_check=lambda *_: True, + ) + self.assertIs(r.decision, Decision.DENY) + + +class FailClosedTests(unittest.TestCase): + def test_permit_unreachable_is_deny_never_allow(self): + """DECISION_UNAVAILABLE is a DENY. This is the fail-open branch, spelled out.""" + + def boom(*_): + raise ConnectionError("authz service unreachable") + + r = decide(CEO_CLAIMS, permit=boom) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("unavailable", r.reason) + + def test_no_authz_client_wired_is_deny_not_skip(self): + r = decide(CEO_CLAIMS, permit=None) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("cannot", r.reason) + + def test_missing_skill_key_is_deny(self): + r = authorize( + ctx(CEO_CLAIMS), + MANIFEST, + delegation_policy=policy(), + permit_check=lambda *_: True, + ) + self.assertIs(r.decision, Decision.DENY) + + +class ClaimParsingTests(unittest.TestCase): + def test_untyped_subject_is_rejected(self): + with self.assertRaises(DelegationError): + parse_claims({"sub": "izzy", "act": {"sub": "repo:X"}}) + + def test_untyped_actor_is_rejected_not_dropped(self): + """A reference that cannot be evaluated must not be treated as satisfied.""" + with self.assertRaises(DelegationError): + parse_claims({"sub": "user:a@b.c", "act": {"sub": "FuzeExecutive"}}) + + def test_subject_without_an_actor_chain_is_rejected(self): + with self.assertRaises(DelegationError): + parse_claims({"sub": "user:a@b.c"}) + + def test_no_claims_is_none_not_an_error(self): + self.assertIsNone(parse_claims(None)) + self.assertIsNone(parse_claims({})) + + def test_chain_is_outermost_first(self): + d = parse_claims(CEO_CLAIMS) + self.assertEqual(d.actor_chain, ("repo:FuzeExecutive", "agent:a2a-shared")) + self.assertEqual(d.subject, "user:ceo@fuzefront.com") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/agent-templates/contracts/a2a/ext/README.md b/agent-templates/contracts/a2a/ext/README.md new file mode 100644 index 0000000..956d926 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/README.md @@ -0,0 +1,24 @@ +# A2A contract extensions + +`contracts/a2a/v1` is **frozen**. Capability added after the freeze lands here as +a versioned extension, never as an edit to v1 and never as a v2 unless the wire +protocol itself must change. + +The mechanism is the one A2A already defines: `AgentCapabilities.extensions[]` on +the Agent Card, which v1's `agent-card.schema.json` already accepts. A callee +publishes an extension URI to say it enforces those rules; a callee that does not +publish it behaves exactly as v1. + +## The test for "extension, not version" + +**Adopting it must not require editing anything frozen, and not adopting it must +not change anything.** Both halves matter, and the first one has teeth: the +`delegated-principal` policy lives in its own `.fuze/a2a-delegation.json` file +precisely because v1's `manifest-a2a-extension.schema.json` sets +`additionalProperties: false` on the `a2a` block — putting the policy there would +have made every adopting repo's manifest fail v1 validation, which would have made +this a version wearing an extension's name. + +| Extension | Version | What it adds | +|---|---|---| +| [`delegated-principal`](delegated-principal/v1/) | 1.0.0 | The ORIGINATING principal rides in the credential (RFC 8693 `sub`/`act`), so a callee can tell a CEO from a warehouse worker arriving through the same repo — which v1 cannot. Plus intersection-not-union authorization and a closed delegable/principal-required/never-delegable classification. | diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md new file mode 100644 index 0000000..73c31ad --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog — `delegated-principal` + +This extension keeps its own changelog. `contracts/a2a/v1/CHANGELOG.md` is frozen +along with the rest of v1, and appending to it would be an edit to the thing this +extension exists to avoid editing. + +## 1.0.0 + +Initial release. + +### Added + +- **The originating principal rides in the credential.** RFC 8693 `sub` (the human + or service the work is ultimately for) plus an `act` chain (the immediate actor, + then its actor). v1 carried a single `caller` string, so a CEO and a warehouse + worker arriving through the same repo were indistinguishable to the callee. +- **Intersection authorization**: `effective = permitted(sub) ∩ brokerable(actor)`. + Both terms are required. The agent cannot grant more than the subject has, and + the subject cannot reach past what the agent may broker. +- **A closed skill classification**: `delegable` / `principal-required` / + `never-delegable`. No fourth member and no default — an unclassified skill is + denied. `never-delegable` is the machine-readable form of the `reach_human` and + `_base` guardrails authz.md §7 already describes in prose. +- `schema/delegated-principal-token.schema.json`, `schema/delegation-policy.schema.json`, + worked examples, and `a2a/delegation.py` wired into `authz.authorize()`. + +### Compatibility + +Additive in both directions, which is the test for an extension rather than a +version: + +- A callee that does not publish the extension URI behaves exactly as v1. +- A caller that sends no delegated credential is handled exactly as v1. +- The policy lives in its own `.fuze/a2a-delegation.json`, **not** under the + manifest's `a2a` block — v1's `manifest-a2a-extension.schema.json` sets + `additionalProperties: false` there, so a `delegation` key would have made every + adopting repo's manifest fail v1 validation. + +### Not included + +The **token-exchange endpoint**. Minting the delegated token before dialling is +the caller's side of the work and is where the remaining implementation sits. +This release is the callee-side enforcement plus the contract it enforces against. diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md new file mode 100644 index 0000000..d5cda4f --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md @@ -0,0 +1,59 @@ +# `delegated-principal` — A2A extension v1.0.0 + +**URI:** `https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1` + +An **extension**, not a v2. `contracts/a2a/v1` is frozen and this tree does not +edit one byte of it. The mechanism is the one A2A already defines: +`AgentCapabilities.extensions[]` on the card (`agent-card.schema.json` → +`AgentExtension`), which v1's schema already accepts. A callee that does not +publish this URI behaves exactly as v1 specifies; a caller that does not send a +delegated credential is handled exactly as v1 specifies. + +## The gap it closes + +v1's `AuthContext.caller` is a **single string**, and `valid_caller_identity()` +accepts a bare repo name or an `Exec-*` principal (authz.md §2). There is no +end-user subject anywhere in the model. + +So a CEO and a warehouse worker arriving through the same calling repo are +**literally indistinguishable to the callee**. If `FuzeExecutive` is in +`FuzePlan`'s `providesTo`, both pass, identically. + +The contract's authors already stated the intent in authz.md §7 — *"`providesTo` +grants the right to ask, not the right to command… A2A adds a front door; it does +not widen any room behind it."* What was missing was the identity precision to act +on it, because identity resolved to a **repo** rather than a **person**. + +## The pod is a delegate, never a principal + +The A2A pod holds **no standing authority over product data**. Its workload +credential authorizes exactly one thing: *may present delegated tokens*. Every bit +of data-plane authority arrives with the call. + +The rejected alternative — give the pod broad rights and have it check the caller +before acting — is the confused deputy. It makes the guard a matter of the pod +remembering to look, which is a convention, not a construction. + +## Four layers + +| # | Layer | Mechanism | Status in v1 | +|---|---|---|---| +| 1 | **Channel** — may repo X reach repo Y at all? | `providesTo` | ✅ exists, unchanged | +| 2 | **Principal** — may *this subject* do this action? | RFC 8693 `sub` + `act` chain, in the credential, never the body | ❌ this extension | +| 3 | **Intersection** — effective = subject's rights **∩** agent's brokerable set | both escalation directions closed | ❌ this extension | +| 4 | **Non-delegable set** — operations no principal reaches via A2A | closed classification, no unclassified bucket | partial (§7 seeds it) | + +Layer 3 is the load-bearing one: the agent cannot grant more than the caller has, +**and** a caller cannot use the agent to reach what the agent may not broker. + +## Files + +| Path | What | +|---|---| +| `spec.md` | the normative rules | +| `schema/delegated-principal-token.schema.json` | the credential claims | +| `schema/delegation-policy.schema.json` | the per-callee classification, closed | +| `examples/` | a token, a policy, and the card capability entry | + +Implementation: `agent-templates/a2a/delegation.py`, wired into `authz.authorize()` +behind `AuthContext.delegation` — absent means v1 behaviour, exactly. diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION b/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json new file mode 100644 index 0000000..295f746 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json @@ -0,0 +1,11 @@ +{ + "capabilities": { + "extensions": [ + { + "uri": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "required": true, + "description": "Calls carry the originating principal (RFC 8693 sub/act). Skills classified principal-required or never-delegable are denied without a conforming credential." + } + ] + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json new file mode 100644 index 0000000..af1dd3e --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json @@ -0,0 +1,9 @@ +{ + "iss": "https://auth.fuzefront.com/", + "aud": "a2a-shared", + "sub": "user:izzy@fuzefront.com", + "act": { + "sub": "repo:FuzeExecutive", + "act": { "sub": "agent:a2a-shared" } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json new file mode 100644 index 0000000..63cc8f8 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json @@ -0,0 +1,13 @@ +{ + "extension": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "authzBaseUrl": "http://fuzefront-backend.fuzefront.svc.cluster.local:3001", + "skills": { + "plan-reader": "delegable", + "plan-editor": "principal-required", + "plan-purge": "never-delegable" + }, + "brokerable": { + "repo:FuzeExecutive": ["plan-reader", "plan-editor"], + "repo:FuzeSales": ["plan-reader"] + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json new file mode 100644 index 0000000..f36d94c --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json", + "title": "Delegated-principal claims (RFC 8693)", + "description": "The delegation claims a callee reads AFTER verifying signature and aud. Validating this shape is never a substitute for that verification: an unverified `sub` is worth less than no `sub`, because it looks like authority.", + "type": "object", + "required": ["sub", "act"], + "properties": { + "sub": { + "type": "string", + "pattern": "^(user|service):[^\\s:]+$", + "description": "The ORIGINATING principal — whom the work is ultimately for. This is the subject Permit decides on. Typed prefix is mandatory; a bare id cannot be resolved to a principal kind and so cannot be checked." + }, + "act": { + "$ref": "#/$defs/Actor", + "description": "The actor chain, outermost first: the immediate actor, then its actor. BOUNDS what `sub` may reach and never widens it." + } + }, + "additionalProperties": true, + "$defs": { + "Actor": { + "type": "object", + "required": ["sub"], + "properties": { + "sub": { + "type": "string", + "pattern": "^(repo|agent|service):[^\\s:]+$", + "description": "A TYPED reference. An untyped actor is rejected: it cannot be matched against the callee's brokerable set, and a rule that cannot be evaluated must not be treated as satisfied." + }, + "act": { "$ref": "#/$defs/Actor" } + }, + "additionalProperties": false + } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json new file mode 100644 index 0000000..009a080 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json", + "title": "Per-callee delegation policy", + "description": "The callee's delegation policy, stored as its OWN file at `.fuze/a2a-delegation.json`. Deliberately NOT a key inside the manifest's `a2a` block: contracts/a2a/v1's manifest-a2a-extension.schema.json sets additionalProperties:false on that block, so adding `delegation` there would make every adopting repo's manifest fail v1 validation. An extension that requires editing the frozen contract to adopt is a version, not an extension \u2014 a separate file is what keeps this additive. It classifies EVERY published skill; there is no default class and no wildcard that could supply one, so an unclassified skill is denied and a new skill cannot acquire the weakest rule in the system merely by being written.", + "type": "object", + "required": [ + "extension", + "skills", + "brokerable" + ], + "additionalProperties": false, + "properties": { + "extension": { + "const": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "description": "Pinned URI. A policy that does not name the extension it implements cannot be checked against the right rules." + }, + "authzBaseUrl": { + "type": "string", + "description": "Base URL of FuzeFront's Security API for the Permit decision (/api/v1/security/authz/check). A product never calls Permit directly. Unreachable is DENY \u2014 there is no fail-open option." + }, + "skills": { + "type": "object", + "description": "role key -> class. Every published skill MUST appear. Absence is DENY, never `delegable`.", + "propertyNames": { + "pattern": "^[a-z0-9_-]+$" + }, + "additionalProperties": { + "enum": [ + "delegable", + "principal-required", + "never-delegable" + ], + "description": "delegable = v1 behaviour, any allowlisted caller. principal-required = verified `sub` AND a Permit ALLOW for that subject. never-delegable = no principal reaches it via A2A regardless of `sub` (the machine-readable form of authz.md \u00a77's reach_human and _base guardrails)." + } + }, + "brokerable": { + "type": "object", + "description": "actor reference -> the role keys that actor may BROKER. The second half of the intersection in spec.md \u00a74: the subject cannot reach past this, no matter what Permit says about the subject. An actor absent here brokers nothing.", + "propertyNames": { + "pattern": "^(repo|agent|service):[^\\s:]+$" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_-]+$" + }, + "description": "Role keys. An empty array means this actor brokers nothing \u2014 which is a meaningful, explicit statement, unlike absence." + } + } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md new file mode 100644 index 0000000..6ecd221 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md @@ -0,0 +1,124 @@ +# `delegated-principal` v1 — normative spec + +Key words per RFC 2119. + +## §1 Negotiation + +A callee that enforces this extension MUST publish it in its Agent Card: + +```json +{ "capabilities": { "extensions": [ + { "uri": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "required": true, + "description": "Calls carry the originating principal; see spec.md" } ] } } +``` + +`required: true` means a call WITHOUT a conforming credential MUST be denied for +every skill this callee classifies as `principal-required` or `never-delegable`. +It does not change the handling of `delegable` skills, which remain exactly v1. + +A callee that does not publish the URI MUST behave as v1. A caller that does not +send the credential to such a callee MUST be handled as v1. **Neither side gets a +new failure mode from an unadopted extension** — that is what makes this an +extension rather than a version. + +## §2 The credential + +Delegation is carried by **RFC 8693 token exchange**, in the credential, and +**NEVER in the request body**. v1 authz.md §1 already forbids trusting the body +for authorization; this extension adds a claim, not an exemption. + +```json +{ + "iss": "https://auth.fuzefront.com/", + "aud": "a2a-shared", + "sub": "user:izzy@fuzefront.com", + "act": { "sub": "repo:FuzeExecutive", + "act": { "sub": "agent:a2a-shared" } } +} +``` + +- **`sub` is the ORIGINATING principal** — the human or service on whose behalf the + work is ultimately done. This is what Permit decides on. +- **`act` is the actor chain**, outermost first: the immediate actor, then its + actor, and so on. It BOUNDS what `sub` can reach; it never widens it. +- The chain MUST be non-empty when `sub` is present, and every entry MUST be a + typed reference (`repo:`, `agent:`, `service:`). A bare id is REJECTED — an + untyped actor cannot be checked against the brokerable set. +- The callee MUST verify the credential's signature and `aud` before reading any + claim. An unverified `sub` is worth less than no `sub`, because it looks like + authority. + +## §3 Where the policy lives + +The policy is its own file: **`.fuze/a2a-delegation.json`**, validated against +`schema/delegation-policy.schema.json`. + +It is deliberately NOT a key inside the manifest's `a2a` block. v1's +`manifest-a2a-extension.schema.json` sets `additionalProperties: false` on that +block, so a `delegation` key there would make **every adopting repo's manifest +fail v1 validation**. An extension that requires editing the frozen contract in +order to adopt it is a version, not an extension. A separate file keeps it +additive — a repo that has not adopted has no such file, and nothing changes for +it. + +## §4 Classification is CLOSED + +Every skill a callee publishes MUST fall in exactly one class: + +| Class | Meaning | +|---|---| +| `delegable` | any allowlisted caller may invoke; no principal needed (v1 behaviour) | +| `principal-required` | requires a verified `sub` AND an ALLOW from Permit for that subject | +| `never-delegable` | no principal reaches it through A2A, regardless of `sub` | + +**There is no fourth bucket.** A skill that is not classified MUST be DENIED, not +treated as `delegable`. An "unclassified" default is how a new endpoint acquires +the weakest rule in the system by being written rather than by being decided — +the same closed-set property applied by the route-ownership and OpenAPI gates. + +`never-delegable` is the machine-readable form of what authz.md §7 already seeds: +`reach_human` for binding decisions, and the `_base` guardrails on `kubectl patch`, +`helm rollback`, `terraform destroy`. + +## §5 Intersection, never union + +For a `principal-required` skill the effective permission is + +``` +effective = permitted(sub, action, resource) ∩ brokerable(actor_chain, skill) +``` + +Both terms MUST be evaluated and both MUST allow. Specifically: + +- **The agent cannot grant more than the subject has.** A subject with no right to + delete a ticket does not acquire one by asking through an agent that has it. +- **The subject cannot reach past what the agent may broker.** A CEO with every + right in Permit still cannot use an agent to reach a skill that agent is not + permitted to broker. + +Implementations MUST NOT short-circuit on either term alone. A union — "allow if +either permits" — is not a weaker version of this rule; it is the opposite of it. + +## §6 Failure is closed and uninformative + +- Missing `sub` on a `principal-required` skill → DENY. +- Unverifiable credential → DENY. +- Permit unreachable → DENY. `DECISION_UNAVAILABLE` is never an allow; there is no + fail-open mode and no configuration flag that creates one. +- Unclassified skill → DENY (§4). +- Untyped actor entry → DENY (§2). + +Wire errors MUST NOT disclose which of these applied, per v1 authz.md §6. The +distinction belongs in the callee's logs. + +## §7 What this extension does NOT do + +- It does not weaken `providesTo`. Channel authz still runs first, and a caller + outside `providesTo` is denied before any of this is consulted. +- It does not move authorization into the request body. +- It does not give the pod standing authority. The pod's own credential authorizes + presenting delegated tokens and nothing else. +- It does not define the token-exchange endpoint. Obtaining the delegated token is + the CALLER's side of the work and is out of scope here — which is where the real + remaining implementation sits.