|
24 | 24 | from vgi_rpc.rpc import AuthContext, PeerResolutionContext, peer_identity_primary |
25 | 25 |
|
26 | 26 | from vgi.arguments import Arguments, Auth, Param, Returns |
27 | | -from vgi.auth import TokenIdentity |
| 27 | +from vgi.auth import IssuedGrant, TokenIdentity |
28 | 28 | from vgi.client import Client |
29 | 29 | from vgi.scalar_function import ScalarFunction |
30 | 30 | from vgi.serve import ( |
@@ -486,6 +486,32 @@ def resolve_token(cls, token: str) -> TokenIdentity | None: |
486 | 486 | return None |
487 | 487 |
|
488 | 488 |
|
| 489 | +class _MintingWorker(Worker): |
| 490 | + """Worker that implements only the optional grant-minting hook.""" |
| 491 | + |
| 492 | + functions = [_DoubleFunc] |
| 493 | + |
| 494 | + @classmethod |
| 495 | + def mint_grant(cls, principal: str, purpose: str, scopes: list[str], ttl_seconds: int) -> IssuedGrant: |
| 496 | + # A sealed self-contained credential: the worker owns the format and |
| 497 | + # the framework never parses it. Capped TTL, to pin that a worker may |
| 498 | + # issue SHORTER than asked. |
| 499 | + ttl = min(ttl_seconds, 3600) |
| 500 | + return IssuedGrant( |
| 501 | + token=f"sealed:{principal}:{':'.join(scopes)}:{purpose}", |
| 502 | + expires_at=1_000_000.0 + ttl, |
| 503 | + grant_id="grant-1", |
| 504 | + ) |
| 505 | + |
| 506 | + |
| 507 | +class _BothWorker(_IntrospectingWorker): |
| 508 | + """Worker that both resolves credentials and mints grants.""" |
| 509 | + |
| 510 | + @classmethod |
| 511 | + def mint_grant(cls, principal: str, purpose: str, scopes: list[str], ttl_seconds: int) -> IssuedGrant: |
| 512 | + return IssuedGrant(token=f"sealed:{principal}", expires_at=1_000_000.0, grant_id="g") |
| 513 | + |
| 514 | + |
489 | 515 | class TestIntrospectResolverDetection: |
490 | 516 | """``resolve_token`` presence is detected by override, not by a flag.""" |
491 | 517 |
|
@@ -516,6 +542,31 @@ def _introspector_ctx(principal: str) -> object: |
516 | 542 | ) |
517 | 543 |
|
518 | 544 |
|
| 545 | +def _minting_ctx(principal: str, *, auth_age_s: float = 0.0) -> object: |
| 546 | + """Build a ``CallContext`` for a caller who authenticated *auth_age_s* ago. |
| 547 | +
|
| 548 | + Minting reads ``auth_time`` off the credential's claims, and a credential |
| 549 | + without one cannot mint at all. That single rule is what stops a grant |
| 550 | + minting another grant — a grant is not IdP-issued, so it carries no |
| 551 | + ``auth_time`` — and what makes a static bearer token, which proves a |
| 552 | + machine holds a secret rather than that a human just authenticated, |
| 553 | + fail closed here. |
| 554 | + """ |
| 555 | + import time |
| 556 | + |
| 557 | + from vgi_rpc.rpc import AuthContext, CallContext |
| 558 | + |
| 559 | + return CallContext( |
| 560 | + auth=AuthContext( |
| 561 | + domain="test", |
| 562 | + authenticated=True, |
| 563 | + principal=principal, |
| 564 | + claims={"auth_time": time.time() - auth_age_s}, |
| 565 | + ), |
| 566 | + emit_client_log=lambda *a, **k: None, |
| 567 | + ) |
| 568 | + |
| 569 | + |
519 | 570 | def _hosted_protocols(worker_cls: type[Worker]) -> frozenset[str]: |
520 | 571 | """Return the protocol names the server built for *worker_cls* hosts. |
521 | 572 |
|
@@ -655,6 +706,111 @@ def test_the_retired_rate_limit_is_not_passed(self) -> None: |
655 | 706 | create_app(_IntrospectingWorker, prefix="/vgi", describe=False) |
656 | 707 |
|
657 | 708 |
|
| 709 | +class TestGrantMinting: |
| 710 | + """``issue_grant`` — the other half of ``vgi_rpc.Identity.v1``. |
| 711 | +
|
| 712 | + Guarded very differently from ``introspect_token``, and deliberately so. |
| 713 | + Introspection answers a question about *somebody else's* credential, so it |
| 714 | + needs an allowlist. Minting produces a credential for the *caller* — there |
| 715 | + is no subject on the wire, so cross-subject minting is closed by |
| 716 | + construction and no allowlist applies. What this repo owns is the wiring: |
| 717 | + a ``mint_grant`` override becomes an ``IdentityImpl`` that offers the |
| 718 | + method, and a worker that never wrote one does not host it. |
| 719 | + """ |
| 720 | + |
| 721 | + @staticmethod |
| 722 | + def _identity(worker_cls: type[Worker], **env: str) -> object: |
| 723 | + from vgi.serve import _build_identity |
| 724 | + |
| 725 | + with pytest.MonkeyPatch.context() as mp: |
| 726 | + mp.delenv("VGI_INTROSPECT_PRINCIPALS", raising=False) |
| 727 | + for key, value in env.items(): |
| 728 | + mp.setenv(key, value) |
| 729 | + return _build_identity(worker_cls, None) |
| 730 | + |
| 731 | + def test_base_worker_has_no_minter(self) -> None: |
| 732 | + """A worker that never wrote the minting gets no issuer.""" |
| 733 | + assert _SingleWorker._grant_minter() is None |
| 734 | + |
| 735 | + def test_override_is_detected(self) -> None: |
| 736 | + """Implementing the hook is what enables it.""" |
| 737 | + minter = _MintingWorker._grant_minter() |
| 738 | + assert minter is not None |
| 739 | + |
| 740 | + def test_minting_alone_hosts_the_protocol(self) -> None: |
| 741 | + """A worker may mint without resolving — the two are independent.""" |
| 742 | + assert "vgi_rpc.Identity.v1" in _hosted_protocols(_MintingWorker) |
| 743 | + |
| 744 | + def test_minting_alone_offers_only_issue_grant(self) -> None: |
| 745 | + """The protocol describes what the worker does, not what it could do.""" |
| 746 | + identity = self._identity(_MintingWorker) |
| 747 | + assert identity is not None |
| 748 | + assert identity.offered_methods() == frozenset({"issue_grant"}) # type: ignore[attr-defined] |
| 749 | + |
| 750 | + def test_minting_alone_needs_no_introspect_allowlist(self) -> None: |
| 751 | + """No allowlist is required to mint: a grant is not an oracle. |
| 752 | +
|
| 753 | + Requiring one would be a startup error for a worker that never exposes |
| 754 | + anybody else's identity — the exact opposite of the guard's purpose. |
| 755 | + """ |
| 756 | + with pytest.MonkeyPatch.context() as mp: |
| 757 | + mp.delenv("VGI_INTROSPECT_PRINCIPALS", raising=False) |
| 758 | + create_app(_MintingWorker, prefix="/vgi", describe=False) |
| 759 | + |
| 760 | + def test_both_hooks_offer_both_methods(self) -> None: |
| 761 | + """Resolving and minting compose.""" |
| 762 | + identity = self._identity(_BothWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a") |
| 763 | + assert identity is not None |
| 764 | + assert identity.offered_methods() == frozenset({"introspect_token", "issue_grant"}) # type: ignore[attr-defined] |
| 765 | + |
| 766 | + def test_the_caller_is_the_subject(self) -> None: |
| 767 | + """The framework supplies the principal; the wire method has no subject field. |
| 768 | +
|
| 769 | + This is the property that makes an allowlist unnecessary, so pin it |
| 770 | + here rather than trusting the signature to stay that way. |
| 771 | + """ |
| 772 | + identity = self._identity(_MintingWorker) |
| 773 | + assert identity is not None |
| 774 | + grant = identity.issue_grant( # type: ignore[attr-defined] |
| 775 | + "nightly-etl", ["read"], 600, _minting_ctx("alice") |
| 776 | + ) |
| 777 | + assert grant.token.startswith("sealed:alice:") |
| 778 | + assert grant.grant_id == "grant-1" |
| 779 | + |
| 780 | + def test_a_worker_may_issue_a_shorter_ttl_than_asked(self) -> None: |
| 781 | + """A worker may narrow the request; it must never widen it.""" |
| 782 | + identity = self._identity(_MintingWorker) |
| 783 | + assert identity is not None |
| 784 | + grant = identity.issue_grant( # type: ignore[attr-defined] |
| 785 | + "long", [], 86_400, _minting_ctx("alice") |
| 786 | + ) |
| 787 | + assert grant.expires_at == 1_000_000.0 + 3600 |
| 788 | + |
| 789 | + def test_stale_authentication_cannot_mint(self) -> None: |
| 790 | + """Minting while the user is present is enforced, not just documented.""" |
| 791 | + from vgi_rpc.rpc._token_identity import StaleAuthError |
| 792 | + |
| 793 | + identity = self._identity(_MintingWorker) |
| 794 | + assert identity is not None |
| 795 | + with pytest.raises(StaleAuthError): |
| 796 | + identity.issue_grant("x", [], 60, _minting_ctx("alice", auth_age_s=86_400)) # type: ignore[attr-defined] |
| 797 | + |
| 798 | + def test_a_credential_with_no_auth_time_cannot_mint(self) -> None: |
| 799 | + """A grant cannot mint another grant, and a static bearer cannot mint at all. |
| 800 | +
|
| 801 | + Neither carries ``auth_time`` — only an IdP-issued credential does — so |
| 802 | + the lineage cannot escape the identity provider. This is the rule that |
| 803 | + makes delegation accountable to a human login rather than to whoever |
| 804 | + currently holds a machine secret. |
| 805 | + """ |
| 806 | + from vgi_rpc.rpc._token_identity import StaleAuthError |
| 807 | + |
| 808 | + identity = self._identity(_MintingWorker) |
| 809 | + assert identity is not None |
| 810 | + with pytest.raises(StaleAuthError): |
| 811 | + identity.issue_grant("x", [], 60, _introspector_ctx("etl-service")) # type: ignore[attr-defined] |
| 812 | + |
| 813 | + |
658 | 814 | class TestAuthUnavailableReExport: |
659 | 815 | """``AuthUnavailableError`` is reachable without naming a vgi_rpc module.""" |
660 | 816 |
|
|
0 commit comments