Make worker identity an advertised, per-worker property — Closes #356 - #375
Draft
conradbzura wants to merge 9 commits into
Draft
Make worker identity an advertised, per-worker property — Closes #356#375conradbzura wants to merge 9 commits into
conradbzura wants to merge 9 commits into
Conversation
conradbzura
force-pushed
the
356-rename-identity-to-peers
branch
from
August 16, 2026 16:07
ccd3360 to
fd8f9ef
Compare
conradbzura
force-pushed
the
356-rename-identity-to-peers
branch
3 times, most recently
from
August 17, 2026 21:24
774a0aa to
2bcbfc5
Compare
The identity name on the credential surface described an expectation about peers, not an identity of self: the logical certificate name a client verifies dialed workers against. Rename it so the vocabulary matches the model and the identity name is freed for its natural future meaning, a worker's own workload identity, without silently flipping semantics for existing callers. The provider level takes peers, the acceptance policy; the material level takes peer, the one name a given credential snapshot verifies its dialed worker against. The singular of the plural carries the relationship exactly: the policy resolves to the name. The term pin was considered and rejected because canonical TLS pinning means certificate or public-key pinning, which this is not. The plural names a policy that admits exactly one name today, so a collection is refused with TypeError rather than failing as an AttributeError inside string normalization; widening it to a set is issue 251. Verification behavior is otherwise unchanged: value equality, channel-pool keying, blank-normalizes-to-None, provider-overrides-material precedence, and the ssl_target_name_override mechanics are all preserved under the new names. Removed keywords fail loudly with TypeError. BREAKING CHANGE: WorkerCredentialsProvider and WorkerCredentials.as_provider accept peers= instead of identity=, and the provider's identity property is now peers. WorkerCredentials.identity is renamed to peer, and identity_channel_options() to peer_channel_options().
gRPC Python cannot verify a SPIFFE URI SAN at the client-side TLS handshake, for two independent reasons: HostNameCertificateVerifier consults DNS SANs, IP SANs, and the CN only when no DNS SAN is present, and a URI-form target name is mangled by host-port splitting besides. This blocks the client half of peer verification by workload identity (issue 251) until upstream gRPC exposes URI-SAN or custom verification to Python. Pin the limitation the way the TLS 1.3 client-rejection blind spot is pinned: the success-path test is a strict xfail constrained to the handshake-drain failure it documents, so an unrelated regression surfaces as a real failure rather than a green expected one, and the day a grpcio upgrade lifts the limitation the suite fails loudly and the blocked work can resume. Two companion tests keep that signal readable. The rejection-path test pins what holds today, which is narrower than its arrangement suggests: no URI SAN is matched at all, so a SPIFFE pin is refused whether or not it matches, and trust in the bundle alone is never sufficient. The control test dials a worker whose certificate carries no URI SAN, proving the pin is applied and unmatched rather than discarded in favor of the address. Without it, a future gRPC that drops an unparsable target-name override would make the strict pin pass and be misread as upstream support. Move the shared started_worker fixture into the integration conftest rather than copy it into the new module, and note in the sibling mTLS suite that identity there means the credential surface's peers and peer, not the advertised worker identity issue 251 introduces.
conradbzura
force-pushed
the
356-rename-identity-to-peers
branch
3 times, most recently
from
August 19, 2026 01:36
f2d60ba to
4d2201f
Compare
The pool decides whether to pass a factory the bind host by inspecting its signature for an explicitly declared, keyword-only parameter. That rule is not specific to the host: it is how the pool passes any optional value down without handing a third-party factory a keyword it never asked for. Generalize the classifier over the parameter name, so a second such value reuses the rule rather than copying it, and call it directly for the host rather than through a host-specific wrapper that would leave two names for one question. Behavior is unchanged.
A client could name exactly one peer it would accept from the workers it dials, which cannot express a pool running more than one kind of workload under a single certificate authority. Compile peers into an acceptance policy that admits a single name, an iterable of names, or a predicate over a candidate name. A blank name and an empty iterable both mean "not configured", like None, rather than a policy accepting nothing, which would reject every peer and read as a silent outage. The policy is modelled on go-spiffe's Authorizer, which covers the same three shapes for the same reason. The predicate form is the seam that makes pattern acceptance additive later rather than a further widening of this parameter. A policy naming exactly one peer is still stamped onto the material as its peer, so single-name verification behaves exactly as before. A policy naming several, or a predicate, stamps nothing: which of them a given connection verifies against cannot be known until there is a worker to verify. The provider answers the acceptance question itself rather than publishing the compiled policy: it reports whether a name is accepted and describes the accepted names for a diagnostic. That keeps one home for the decision instead of a value each caller must interpret. A provider may expose the whole surface or none of it, where none opts out of identity gating entirely, and coercion rejects one offering only part of it so a half-implemented contract fails at configuration rather than from inside a proxy. BREAKING CHANGE: WorkerCredentialsProvider.peers returns the accepted names as a frozenset, or the predicate, rather than a single string.
A worker was verified against a logical name the client configured: one expectation for a whole fleet. No worker declared anything about itself, so two workers signed by one authority were indistinguishable and a client could not say which of them it meant to reach. Give a worker an identity, a logical name for the workload it is, carried in its certificate and advertised through discovery alongside its address. A client admits a worker only when its accepted names cover the identity that worker advertised, then verifies that name rather than a fleet-wide one at the handshake. Which name a connection proves is therefore chosen per worker, and connections pinned to different peers never share a pooled channel. Admission has two states and no others. With no accepted names configured, advertisements are ignored entirely and a connection verifies against the material's own peer or the dialed address. With accepted names configured, a worker is admitted only if it advertises one of them, and one advertising nothing is rejected whatever the shape of the policy. Deriving that second question from how many names happened to be accepted would make widening an accept list narrow what is admitted, and make a single name disagree with a predicate accepting only that name. The advertisement selects which name is verified; it never widens what is accepted. It is applied only where a policy exists to have accepted it, so a worker's own claim can never displace a name the caller configured. A worker claiming a name it holds no certificate for is admitted by the gate and then rejected by the handshake, which is pinned by test. Security therefore does not rest on the discovery plane being trustworthy, and wool's built-in backends are not authenticated. Only availability does: forged advertisements cost connection attempts that go nowhere. A pool that both spawns workers and dispatches through them hands one provider to both roles, so it can be configured to refuse everything it starts. That fails at construction where no worker could ever be admitted, and warns where a pool may legitimately contribute capacity it does not itself dial. An identity set without credentials fails at construction too, rather than from inside a spawned subprocess. Nothing checks that a declared identity appears in the worker's own certificate, because wool does not parse certificates. A mismatch surfaces at the first handshake as a diagnosable failure carrying whatever the TLS stack reported, including for a worker's own stop RPC. Verification is outbound only. A worker still serves any caller its authority signed. BREAKING CHANGE: A client that configures accepted peer names no longer dials a worker advertising no identity. To upgrade a fleet that does not yet advertise, roll the workers out with an identity first — a client predating this field ignores the advertisement — and then configure the accepted names on the clients.
Cover the identity a worker claims and advertises, the shapes a client may accept it in, and the property the design rests on: an advertisement selects which name gets verified and never widens what is accepted, so a worker cannot claim its way into a pool. State the two limits plainly as well — verification is outbound only, and a declared identity is not checked against the worker's own certificate. Add a decision chart to the pool-modes section resolving the constructor's non-deprecated overloads: which mode a call lands in, what that then requires of the worker factory, and where the bind-host question fits. The chart marks that last question as orthogonal, since it is answered by signature inspection rather than by whichever overload matched.
Four of these named subjects that do not exist. test___enter___ and test_current asserted that WorkerCredentials is not a context manager and has no current method, neither of which anyone proposed; they pin Python's own defaults rather than this package's behaviour. The _eager_provider helper was referenced nowhere. And one concurrency test set a private _window attribute that no longer exists anywhere in the source, so the line created a fresh attribute nothing reads and the test stopped arranging what its docstring describes — it now sets the debounce interval through the public fresh_for parameter instead. Three more asserted only isinstance on server and client credentials, across one mutual flag value or on repeated access. The property-based test over the mutual domain already covers repeated access for both methods, both types, and their consistency, so the three were strictly subsumed.
Every test in TestLanDiscoverySubscriber drives real zeroconf: live mDNS service registration, real multicast discovery, and wall-clock waits of up to two seconds. Running them under the default selection meant the unit suite took a network dependency and paid tens of seconds for it, and a failure there could as easily mean a hostile network as a regression. They still run, behind the marker that already separates the rest of the cross-boundary suite.
conradbzura
force-pushed
the
356-rename-identity-to-peers
branch
from
August 20, 2026 00:23
547bc35 to
f905595
Compare
The rotation helper called refresh once and asserted the material had changed. Refreshing.refresh joins a refresh already in flight rather than starting a second, and the zero freshness interval these tests configure means the read preceding the rotation left one in flight. That flight was derived from the pre-rotation files, so joining it returns exactly the value the caller is waiting to see replaced. Repeat the refresh until the re-read lands. No sleep is needed: each call either joins the stale flight or starts a fresh one. Surfaced as a CI failure on one Python version against a tree that had passed on the same content hours earlier.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A worker was verified against a logical name the client configured: one expectation for a whole fleet. No worker declared anything about itself, so two workers signed by one authority were indistinguishable, and a client could not say which of them it meant to reach.
A worker now carries an
identity— a logical name for the workload it is, independent of where it was scheduled — advertised through discovery alongside its address. A client declares which names it accepts, admits a worker only when that policy covers the identity the worker advertised, and verifies that name at the handshake. Which name a connection proves is chosen per worker, so connections pinned to different peers never share a pooled channel.Admission has two states. With no accepted names configured, advertisements are ignored entirely and a connection verifies against the material's own
peeror the dialed address. With accepted names configured, a worker is admitted only if it advertises one of them, and a worker advertising nothing is rejected whatever the shape of the policy. Cardinality does not enter into it: an accept-list of one behaves exactly as an accept-list of two, and exactly as a predicate accepting only that one name.Freeing
identityfor this meaning requires renaming what it meant before, so the credential surface breaks.identity=becomespeers=onWorkerCredentialsProviderandWorkerCredentials.as_provider,WorkerCredentials.identitybecomespeer, andidentity_channel_options()becomespeer_channel_options(). There is no deprecation shim — removed keywords fail withTypeError. The break is deliberate while #249 adoption is young: a caller passingidentity="wool-worker.svc"as an expectation must never have it quietly become an advertised self-identity.The worker factory surface breaks too, in three ways. A factory pre-supplying
hostoridentitythroughfunctools.partialno longer keeps that value — the pool's wins — so a factory that must own its binding has to decline the keyword rather than pre-supply it. A**kwargs-forwarding factory now receives the bind host it was previously denied. Andisinstanceagainst the factory protocols raisesTypeError, since they are no longerruntime_checkable.The advertisement selects which name is verified and never widens what is accepted. It is applied only where a policy exists to have accepted it, so a worker's own claim cannot displace a name the caller configured. A worker claiming a name it holds no certificate for is admitted by the gate and then rejected by the handshake, which is pinned by test. Security therefore does not rest on the discovery plane being trustworthy; only availability does, since forged advertisements cost connection attempts that go nowhere.
Two limits are worth stating up front. Verification is outbound only — who a client will dial. A worker still serves any caller its authority signed, and refusing callers by their identity is #251. And nothing checks that a declared identity appears in the worker's own certificate, because wool does not parse certificates; a mismatch surfaces at the first handshake as a diagnosable failure.
Upgrading a fleet that does not yet advertise runs workers-first: a client predating this field ignores the advertisement, so roll the workers out with an identity, then configure the accepted names on the clients.
Closes #356
Proposed changes
Rename the credential surface
WorkerCredentials.identitybecomespeer, normalized in__post_init__;identity_channel_options()becomespeer_channel_options();as_provider(identity=)andWorkerCredentialsProvider(identity=)becomepeers=, with the provider'sidentityproperty becomingpeers. The pairing carries the semantics:peersnames the acceptance policy,peernames the one artifact a snapshot verifies against and rides the channel-pool key.pinwas considered for the material field and rejected, since canonical TLS pinning means certificate or public-key pinning.Accept several peer names or a predicate
Compile
peersinto an internal policy admitting a single name, an iterable of names, or a predicate over a candidate name. A blank name and an empty iterable both mean "not configured", likeNone, rather than a policy accepting nothing that would reject every peer and read as a silent outage. The shape follows go-spiffe'sAuthorizer, which covers the same three cases; the predicate form is the seam that makes the pattern acceptance #355 needs additive rather than a further widening of this parameter.The provider answers the acceptance question rather than publishing the compiled policy: it reports whether a candidate name is accepted and describes the accepted names for a diagnostic. That leaves one home for the decision instead of a value each caller must interpret. A provider exposes the whole surface or none of it, where none opts out of identity gating, and
coercerejects one offering only part of it so a half-implemented contract fails at configuration rather than from inside a proxy.Advertise a worker's identity
Add
identityto the worker metadata and to protobuf field 9, threaded fromLocalWorkerandWorkerPoolthroughWorkerProcess. The LAN backend gains a TXT key that tolerates its own absence, so records published by workers predating the field still parse; the local backend rides the protobuf converters unchanged.WorkerMetadatanormalizes the field on construction, so every path that produces one — the wire, a discovery backend, a caller building metadata directly — collapses a blank name toNonethe same way. A worker's identity requires credentials, since a name with no certificate behind it proves nothing.Verify each worker against what it advertised
Add an identity arm to the proxy's admission gate, reported after security and version. It applies the configured policy to the advertised name and never treats the advertisement as authority.
WorkerConnectiongains apeerargument applied during pool-key resolution, so the chosen name reachesgrpc.ssl_target_name_overrideper worker; the proxy supplies it only where a policy exists to have accepted it. A worker's own stop RPC pins its identity for the same reason: a policy naming several peers stamps nothing on the material, and a certificate carrying only a logical name cannot be verified against an address.Enumerate the worker factory shapes
The pool passes a factory two optional keywords: the bind host prescribed by its discovery publisher, and the identity its workers should advertise. Whether a factory receives either is a property of that factory, so the surface is four shapes along two orthogonal axes —
BoundWorkerFactory,WorkerFactory,IdentifiedBoundWorkerFactory, andIdentifiedWorkerFactory— joined by a publicWorkerFactoryLikealias. Whichever keyword a factory does not take, it owns: its own binding, or the name its workers advertise.Enumeration is forced rather than chosen. Composing "accepts host" and "accepts identity" as two constraints on one callable needs a type intersection, which Python does not have. Protocol inheritance resolves
__call__by MRO and silently keeps only the first base, so a factory taking only a host satisfies a protocol whose name claims both — no error, just a weaker type. An overloaded__call__does produce a genuine intersection, but overloads are a union for the caller and an intersection for the implementer, so a four-overload protocol rejects every legacy shape.WorkerFactoryLikeis the only name callers need, so the shapes behind it can be replaced when the language grows an intersection.identityis required in the two identified arms. Conforming to one is the factory's statement that it wants the value, so the pool always sends one, andNonemeans the pool has no name to give.runtime_checkablecomes off all four, because for a protocol whose only member is__call__anisinstancecheck tests nothing but callability and admits any callable at all.Classify factories by binding the call
Replace the keyword classifier with
accepts_kwarg,requires_kwarg, andunbindable_call, which decide by binding the call the pool will actually make rather than by inspecting parameter kinds. No kind-keyed rule is correct: a positional-or-keyword parameter collides with a forwarded positional at one argument count and binds cleanly at another, so its classification is a property of the call.accepts_kwargbinds partially, which is what keeps the question about one keyword. A complete bind fails whenever any other required parameter is unsatisfied, so probing one keyword would report on another, and a factory declaring bothhostandidentitywithout defaults would be found to accept neither.requires_kwarginspects the parameter instead of attempting a call, because a failed bind does not say which argument it wanted; reading anyTypeErroras evidence about the queried name reports a factory requiring some unrelated keyword as requiring this one.unbindable_callanswers what the per-keyword predicates deliberately do not — whether the call is satisfiable at all — so the pool refuses a factory it cannot call and names the argument at fault.Two behaviours change as a consequence. A
**kwargs-forwarding factory now receives the publisher's bind host, where before it was classified bound andLocalWorkerfell back to loopback, leaving the worker unreachable at the address a LAN pool advertised for it. And a pre-supplyingfunctools.partialno longer suppresses the keyword it binds, since that call succeeds and the call-site value overrides.Diagnose only what a signature proves
The pool's
identityhas three states, distinguished because an explicitNoneis a value and an unset parameter is not. Unset withholds the keyword entirely and the factory owns the name its workers advertise. Astror an explicitNoneis delivered to any factory that can receive it, and withheld withIneffectiveIdentityWarningwhen it cannot.Passing the value is all the pool guarantees. Whether a factory that accepts an identity then honors it is not a question a signature answers, so the pool asserts nothing about it. The self-refusal check — which raises for an ephemeral pool whose own
peerspolicy would refuse everything it starts, and warns for a hybrid one — therefore runs only when the pool owns the default factory and can predict what its workers advertise. With a custom factory that prediction is unfounded, and the cost is a startup timeout in place of a construction error, documented on the parameter so an operator knows to suspect it.Three diagnostics survive, each decidable from a signature: a configured identity the factory cannot receive, a factory requiring an identity the pool has none to give, and a factory the pool cannot call at all. An identity set without credentials still raises at construction rather than from inside a spawned subprocess.
Pin the upstream limitation blocking SPIFFE
tests/integration/test_workload_identity.pypins why #355 cannot proceed: gRPC Python's client-side name check never consults URI SANs, so a SPIFFE identity cannot be verified at the handshake. The success path is a strictxfailconstrained to the drain it documents, so an unrelated regression surfaces as a real failure rather than a green expected one. A control test dials a worker whose certificate carries no URI SAN at all, which distinguishes a future unexpected pass meaning "URI-SAN matching landed" from one meaning "the override was silently dropped".Extend the certificate test helpers
Add
generate_authority, which builds a certificate authority a caller can then hand togenerate_ca_and_leaf, so several leaves chain to one trust bundle while carrying distinct names. Without it no test can tell two workers apart by identity rather than by authority, which is exactly the distinction this PR turns on. The self-signed and issued paths now share one code path, so the two cannot drift into differently shaped certificates.Test cases
TestWorkerCredentialsWorkerCredentialsis instantiatedpeer, with blank or None collapsing to NoneTestWorkerCredentialspeerWorkerCredentialsis instantiatedTypeErrornaming where several names belongTestWorkerCredentialspeer_channel_options()is calledTestWorkerCredentialsProviderTestWorkerCredentialsProviderTestWorkerCredentialsProviderTestWorkerCredentialsProviderpeersis a locally defined predicateTestWorkerCredentialsProviderTypeErrornaming what is missingTestAcceptsKwarg**kwargs, or not at all, and the same positional parameter under differing argument counts**kwargssink opts in and a positional declaration's answer differs with the countTestAcceptsKwargTestRequiresKwargTestUnbindableCallTestWorkerProxyIdentityAdmissionTestWorkerProxyIdentityAdmissionValueError, since the quorum can never be satisfiedTestWorkerProxyIdentityAdmissionTestLanDiscoveryPublisherTestLanDiscoverySubscriberTestLanDiscoverySubscriberTestWorkerConnectionTestWorkerConnectionTestLocalWorkerValueErrorwhen no credentials back itTestLocalWorkerstop()is calledTestWorkerPoolidentity, absorbing keywords, pre-supplying its own, or unable to receive oneTestWorkerPoolIdentifiedWorkerFactorydeclares, withhostandidentityboth requiredTestWorkerPoolidentityand declaring nohostTestWorkerPoolNone, or a name, against factories that can and cannot receive oneTestWorkerPoolidentityTestWorkerPoolpeerspolicy would refuse the workers it spawns, over the default factoryTestWorkerPooltest_worker_identity(integration)test_worker_identity(integration)test_worker_identity(integration)test_identity_mtls(integration)test_workload_identity(integration)xfailshould fail today and fail loudly when gRPC gains URI-SAN verificationtest_helpers