prokura 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. prokura-0.0.1/PKG-INFO +56 -0
  2. prokura-0.0.1/README.md +36 -0
  3. prokura-0.0.1/pyproject.toml +41 -0
  4. prokura-0.0.1/setup.cfg +4 -0
  5. prokura-0.0.1/src/prokura/__init__.py +61 -0
  6. prokura-0.0.1/src/prokura/agent_account_scope.py +155 -0
  7. prokura-0.0.1/src/prokura/authenticators/__init__.py +34 -0
  8. prokura-0.0.1/src/prokura/authenticators/authority.py +265 -0
  9. prokura-0.0.1/src/prokura/authenticators/client.py +75 -0
  10. prokura-0.0.1/src/prokura/authenticators/models.py +309 -0
  11. prokura-0.0.1/src/prokura/authority_inventory.py +342 -0
  12. prokura-0.0.1/src/prokura/authority_projection.py +241 -0
  13. prokura-0.0.1/src/prokura/authority_providers/__init__.py +13 -0
  14. prokura-0.0.1/src/prokura/authority_registry.py +430 -0
  15. prokura-0.0.1/src/prokura/authority_registry_client.py +235 -0
  16. prokura-0.0.1/src/prokura/authority_registry_config.py +365 -0
  17. prokura-0.0.1/src/prokura/client.py +135 -0
  18. prokura-0.0.1/src/prokura/connection_edges.py +209 -0
  19. prokura-0.0.1/src/prokura/connector_app_resolution.py +82 -0
  20. prokura-0.0.1/src/prokura/consent_state.py +227 -0
  21. prokura-0.0.1/src/prokura/consent_state_adapters.py +130 -0
  22. prokura-0.0.1/src/prokura/contract.py +320 -0
  23. prokura-0.0.1/src/prokura/custom_authorities/__init__.py +30 -0
  24. prokura-0.0.1/src/prokura/delegated_credentials/__init__.py +4 -0
  25. prokura-0.0.1/src/prokura/delegated_credentials/access_map.py +225 -0
  26. prokura-0.0.1/src/prokura/delegated_credentials/automation_access.py +3318 -0
  27. prokura-0.0.1/src/prokura/delegated_credentials/cache_io.py +44 -0
  28. prokura-0.0.1/src/prokura/delegated_credentials/cache_settings.py +121 -0
  29. prokura-0.0.1/src/prokura/delegated_credentials/cards/__init__.py +48 -0
  30. prokura-0.0.1/src/prokura/delegated_credentials/cards/cache.py +356 -0
  31. prokura-0.0.1/src/prokura/delegated_credentials/cards/handles.py +66 -0
  32. prokura-0.0.1/src/prokura/delegated_credentials/cards/migration.py +101 -0
  33. prokura-0.0.1/src/prokura/delegated_credentials/cards/model.py +428 -0
  34. prokura-0.0.1/src/prokura/delegated_credentials/cards/persistence.py +159 -0
  35. prokura-0.0.1/src/prokura/delegated_credentials/cards/resolver.py +199 -0
  36. prokura-0.0.1/src/prokura/delegated_credentials/cards/service.py +379 -0
  37. prokura-0.0.1/src/prokura/delegated_credentials/cards/store.py +234 -0
  38. prokura-0.0.1/src/prokura/delegated_credentials/catalog/__init__.py +52 -0
  39. prokura-0.0.1/src/prokura/delegated_credentials/catalog/authorization.py +344 -0
  40. prokura-0.0.1/src/prokura/delegated_credentials/catalog/drift.py +308 -0
  41. prokura-0.0.1/src/prokura/delegated_credentials/catalog/hashing.py +33 -0
  42. prokura-0.0.1/src/prokura/delegated_credentials/catalog/models.py +129 -0
  43. prokura-0.0.1/src/prokura/delegated_credentials/catalog/publisher.py +203 -0
  44. prokura-0.0.1/src/prokura/delegated_credentials/catalog/reconcile.py +168 -0
  45. prokura-0.0.1/src/prokura/delegated_credentials/catalog/resolver.py +131 -0
  46. prokura-0.0.1/src/prokura/delegated_credentials/catalog/runtime_cache.py +131 -0
  47. prokura-0.0.1/src/prokura/delegated_credentials/catalog/source.py +26 -0
  48. prokura-0.0.1/src/prokura/delegated_credentials/catalog/store.py +124 -0
  49. prokura-0.0.1/src/prokura/delegated_credentials/consent_denial.py +533 -0
  50. prokura-0.0.1/src/prokura/delegated_credentials/credential_view.py +304 -0
  51. prokura-0.0.1/src/prokura/delegated_credentials/durable_io.py +108 -0
  52. prokura-0.0.1/src/prokura/delegated_credentials/live_grant.py +145 -0
  53. prokura-0.0.1/src/prokura/delegated_credentials/named_service_policy.py +296 -0
  54. prokura-0.0.1/src/prokura/delegated_credentials/oauth/__init__.py +42 -0
  55. prokura-0.0.1/src/prokura/delegated_credentials/oauth/account_requirements.py +335 -0
  56. prokura-0.0.1/src/prokura/delegated_credentials/oauth/authority.py +198 -0
  57. prokura-0.0.1/src/prokura/delegated_credentials/oauth/client_metadata.py +535 -0
  58. prokura-0.0.1/src/prokura/delegated_credentials/oauth/clients.py +168 -0
  59. prokura-0.0.1/src/prokura/delegated_credentials/oauth/config.py +669 -0
  60. prokura-0.0.1/src/prokura/delegated_credentials/oauth/consent.py +885 -0
  61. prokura-0.0.1/src/prokura/delegated_credentials/oauth/flow.py +126 -0
  62. prokura-0.0.1/src/prokura/delegated_credentials/oauth/grants.py +104 -0
  63. prokura-0.0.1/src/prokura/delegated_credentials/oauth/metadata.py +143 -0
  64. prokura-0.0.1/src/prokura/delegated_credentials/oauth/pkce.py +21 -0
  65. prokura-0.0.1/src/prokura/delegated_credentials/oauth/store.py +574 -0
  66. prokura-0.0.1/src/prokura/delegated_credentials/oauth/surface_policy.py +833 -0
  67. prokura-0.0.1/src/prokura/delegated_credentials/serving.py +208 -0
  68. prokura-0.0.1/src/prokura/delegated_mcp.py +309 -0
  69. prokura-0.0.1/src/prokura/delegated_to_kdcube/__init__.py +105 -0
  70. prokura-0.0.1/src/prokura/delegated_to_kdcube/adapters.py +239 -0
  71. prokura-0.0.1/src/prokura/delegated_to_kdcube/broker.py +557 -0
  72. prokura-0.0.1/src/prokura/delegated_to_kdcube/client.py +266 -0
  73. prokura-0.0.1/src/prokura/delegated_to_kdcube/config.py +58 -0
  74. prokura-0.0.1/src/prokura/delegated_to_kdcube/consent_demand.py +721 -0
  75. prokura-0.0.1/src/prokura/delegated_to_kdcube/models.py +547 -0
  76. prokura-0.0.1/src/prokura/delegated_to_kdcube/oauth.py +179 -0
  77. prokura-0.0.1/src/prokura/delegated_to_kdcube/operations.py +675 -0
  78. prokura-0.0.1/src/prokura/delegated_to_kdcube/preflight.py +443 -0
  79. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/__init__.py +27 -0
  80. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/email.py +28 -0
  81. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/generic_oauth.py +174 -0
  82. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/google.py +88 -0
  83. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/linkedin.py +202 -0
  84. prokura-0.0.1/src/prokura/delegated_to_kdcube/providers/slack.py +92 -0
  85. prokura-0.0.1/src/prokura/delegated_to_kdcube/public_base.py +57 -0
  86. prokura-0.0.1/src/prokura/delegated_to_kdcube/store.py +406 -0
  87. prokura-0.0.1/src/prokura/federated_tokens/__init__.py +32 -0
  88. prokura-0.0.1/src/prokura/federated_tokens/data_bus.py +363 -0
  89. prokura-0.0.1/src/prokura/hub/__init__.py +4 -0
  90. prokura-0.0.1/src/prokura/hub/authenticator_store.py +275 -0
  91. prokura-0.0.1/src/prokura/hub/edges.py +650 -0
  92. prokura-0.0.1/src/prokura/hub/resolver.py +507 -0
  93. prokura-0.0.1/src/prokura/mcp_consent.py +294 -0
  94. prokura-0.0.1/src/prokura/mcp_metadata.py +134 -0
  95. prokura-0.0.1/src/prokura/mcp_result.py +351 -0
  96. prokura-0.0.1/src/prokura/mcp_tool_enforcement.py +260 -0
  97. prokura-0.0.1/src/prokura/named_service_admission.py +511 -0
  98. prokura-0.0.1/src/prokura/named_service_boundary.py +229 -0
  99. prokura-0.0.1/src/prokura/py.typed +1 -0
  100. prokura-0.0.1/src/prokura/request_auth.py +241 -0
  101. prokura-0.0.1/src/prokura.egg-info/PKG-INFO +56 -0
  102. prokura-0.0.1/src/prokura.egg-info/SOURCES.txt +132 -0
  103. prokura-0.0.1/src/prokura.egg-info/dependency_links.txt +1 -0
  104. prokura-0.0.1/src/prokura.egg-info/requires.txt +6 -0
  105. prokura-0.0.1/src/prokura.egg-info/top_level.txt +1 -0
  106. prokura-0.0.1/tests/test_access_map.py +158 -0
  107. prokura-0.0.1/tests/test_account_store.py +67 -0
  108. prokura-0.0.1/tests/test_authority_projection.py +45 -0
  109. prokura-0.0.1/tests/test_authority_registry.py +90 -0
  110. prokura-0.0.1/tests/test_authority_registry_config.py +248 -0
  111. prokura-0.0.1/tests/test_cache_settings.py +49 -0
  112. prokura-0.0.1/tests/test_card_service.py +125 -0
  113. prokura-0.0.1/tests/test_catalog_documents.py +160 -0
  114. prokura-0.0.1/tests/test_catalog_publisher.py +64 -0
  115. prokura-0.0.1/tests/test_client.py +59 -0
  116. prokura-0.0.1/tests/test_connector_app_resolution.py +19 -0
  117. prokura-0.0.1/tests/test_consent_demand.py +145 -0
  118. prokura-0.0.1/tests/test_consent_state.py +124 -0
  119. prokura-0.0.1/tests/test_contract.py +41 -0
  120. prokura-0.0.1/tests/test_credential_view.py +101 -0
  121. prokura-0.0.1/tests/test_delegated_mcp.py +274 -0
  122. prokura-0.0.1/tests/test_federated_tokens.py +107 -0
  123. prokura-0.0.1/tests/test_host_ports.py +92 -0
  124. prokura-0.0.1/tests/test_mcp_consent.py +120 -0
  125. prokura-0.0.1/tests/test_mcp_result.py +109 -0
  126. prokura-0.0.1/tests/test_mcp_tool_enforcement.py +104 -0
  127. prokura-0.0.1/tests/test_named_service_admission.py +139 -0
  128. prokura-0.0.1/tests/test_named_service_boundary.py +96 -0
  129. prokura-0.0.1/tests/test_oauth_config.py +55 -0
  130. prokura-0.0.1/tests/test_oauth_grants.py +84 -0
  131. prokura-0.0.1/tests/test_public_import_surface.py +19 -0
  132. prokura-0.0.1/tests/test_request_auth.py +107 -0
  133. prokura-0.0.1/tests/test_serving_ports.py +71 -0
  134. prokura-0.0.1/tests/test_surface_policy.py +170 -0
prokura-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: prokura
3
+ Version: 0.0.1
4
+ Summary: One authority for delegated access: identity cards for agents and automations, resolved on every call at the service boundary.
5
+ Author: Elena Viter
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/elenaviter/app-ecosystem
8
+ Project-URL: Repository, https://github.com/elenaviter/app-ecosystem
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: aiohttp<4,>=3.9
16
+ Requires-Dist: httpx<1,>=0.27
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest<10,>=8; extra == "test"
19
+ Requires-Dist: pytest-asyncio<2,>=0.23; extra == "test"
20
+
21
+ # prokura
22
+
23
+ One authority for delegated access.
24
+
25
+ Prokura is an old commercial-law institution: delegated signing authority
26
+ that lives in a register. Third parties verify against the register, not
27
+ against any letter the holder carries, and revocation takes effect at the
28
+ register. This package is that idea, built for agents and automations.
29
+
30
+ The identity provider appeared because many applications needed one
31
+ authority on who the user is. Delegated access needs its own authority the
32
+ same way. Every caller, an agent, a sub-agent, an automation, has its own
33
+ identity card. The authority attached to it lives in one central record:
34
+ acting for which user, on which connected accounts, which operations,
35
+ which claims, until when. A guarded boundary resolves the current
36
+ authority on every call. Calls carry only plain facts, the actor identity
37
+ and the wanted grant. Authorization evidence does not travel with calls,
38
+ and an edit or revocation of the card applies on the very next call.
39
+
40
+ **Version 0.0.1 is a development release.** The package contains the portable
41
+ authority implementation: authority and authenticator contracts, durable
42
+ identity-card and capability-catalog histories, delegated OAuth and connected-
43
+ account lifecycle, identity-edge resolution, per-call named-service and
44
+ managed-surface admission, structured denial contracts, and explicit ports for
45
+ host storage, dispatch, identity, and live-delivery capabilities. KDCube
46
+ consumes these modules through thin host adapters, and the Connection Hub app
47
+ in this repository is the first frontend over the package.
48
+
49
+ Home: https://github.com/elenaviter/app-ecosystem
50
+
51
+ Architecture and contracts:
52
+
53
+ - [Package boundary and extraction](../../docs/prokura/package/extraction-architecture.md)
54
+ - [Delegated authority and admission](../../docs/prokura/package/delegated-authority-and-admission.md)
55
+ - [Delegated access cards](../../docs/prokura/package/delegated-cards.md)
56
+ - [OAuth delegated credential protocol](../../docs/prokura/package/oauth-delegated-credential-protocol.md)
@@ -0,0 +1,36 @@
1
+ # prokura
2
+
3
+ One authority for delegated access.
4
+
5
+ Prokura is an old commercial-law institution: delegated signing authority
6
+ that lives in a register. Third parties verify against the register, not
7
+ against any letter the holder carries, and revocation takes effect at the
8
+ register. This package is that idea, built for agents and automations.
9
+
10
+ The identity provider appeared because many applications needed one
11
+ authority on who the user is. Delegated access needs its own authority the
12
+ same way. Every caller, an agent, a sub-agent, an automation, has its own
13
+ identity card. The authority attached to it lives in one central record:
14
+ acting for which user, on which connected accounts, which operations,
15
+ which claims, until when. A guarded boundary resolves the current
16
+ authority on every call. Calls carry only plain facts, the actor identity
17
+ and the wanted grant. Authorization evidence does not travel with calls,
18
+ and an edit or revocation of the card applies on the very next call.
19
+
20
+ **Version 0.0.1 is a development release.** The package contains the portable
21
+ authority implementation: authority and authenticator contracts, durable
22
+ identity-card and capability-catalog histories, delegated OAuth and connected-
23
+ account lifecycle, identity-edge resolution, per-call named-service and
24
+ managed-surface admission, structured denial contracts, and explicit ports for
25
+ host storage, dispatch, identity, and live-delivery capabilities. KDCube
26
+ consumes these modules through thin host adapters, and the Connection Hub app
27
+ in this repository is the first frontend over the package.
28
+
29
+ Home: https://github.com/elenaviter/app-ecosystem
30
+
31
+ Architecture and contracts:
32
+
33
+ - [Package boundary and extraction](../../docs/prokura/package/extraction-architecture.md)
34
+ - [Delegated authority and admission](../../docs/prokura/package/delegated-authority-and-admission.md)
35
+ - [Delegated access cards](../../docs/prokura/package/delegated-cards.md)
36
+ - [OAuth delegated credential protocol](../../docs/prokura/package/oauth-delegated-credential-protocol.md)
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "prokura"
7
+ version = "0.0.1"
8
+ description = "One authority for delegated access: identity cards for agents and automations, resolved on every call at the service boundary."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Elena Viter" }]
12
+ requires-python = ">=3.10"
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Security",
18
+ ]
19
+ dependencies = [
20
+ "aiohttp>=3.9,<4",
21
+ "httpx>=0.27,<1",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ test = [
26
+ "pytest>=8,<10",
27
+ "pytest-asyncio>=0.23,<2",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/elenaviter/app-ecosystem"
32
+ Repository = "https://github.com/elenaviter/app-ecosystem"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.setuptools.package-data]
38
+ prokura = ["py.typed"]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,61 @@
1
+ """Prokura: one authority for delegated access.
2
+
3
+ Prokura is an old commercial-law institution: delegated signing authority
4
+ that lives in a register, verified against the register rather than any
5
+ carried letter, revocable at the register. This package is that idea,
6
+ built for agents and automations: every caller has its own identity card,
7
+ the authority lives in one central record, and a guarded boundary resolves
8
+ the current authority on every call.
9
+
10
+ Version 0.0.1 is a development release. The host-neutral authority core is
11
+ present; runtime adapters, per-call admission orchestration, and the
12
+ Connection Hub frontend are moving in subsequent extraction slices. See the
13
+ repository for status: https://github.com/elenaviter/app-ecosystem
14
+ """
15
+
16
+ from importlib import import_module
17
+ from typing import Any
18
+
19
+ __version__ = "0.0.1"
20
+
21
+ _EXPORTS: dict[str, str] = {}
22
+ for name in (
23
+ "NAMESPACE",
24
+ "CONNECTION_CATALOG",
25
+ "CONNECTION_STATUS",
26
+ "CONNECTION_GET_TOKEN",
27
+ "CONNECTION_DISCONNECT",
28
+ "OAUTH_START",
29
+ "AGENT_GRANT_GET_TOKEN",
30
+ "AGENT_GRANT_CHECK",
31
+ "CONNECTION_OPERATIONS",
32
+ "ConnectionOperationSpec",
33
+ "build_connection_operations",
34
+ "Connection",
35
+ "ConnectionToken",
36
+ "CatalogEntry",
37
+ "ClientApp",
38
+ "AmbiguousConnectionAccount",
39
+ ):
40
+ _EXPORTS[name] = "prokura.contract"
41
+ for name in ("ConnectionsClient", "ConnectionsError", "ConnectionsTransport"):
42
+ _EXPORTS[name] = "prokura.client"
43
+ for name in (
44
+ "AuthorityProviderSpec",
45
+ "AuthorityRegistry",
46
+ "AuthorityResolution",
47
+ "CredentialEnvelope",
48
+ "authority_provider_spec_from_declaration",
49
+ ):
50
+ _EXPORTS[name] = "prokura.authority_registry"
51
+
52
+ __all__ = ["__version__", *_EXPORTS]
53
+
54
+
55
+ def __getattr__(name: str) -> Any:
56
+ module_name = _EXPORTS.get(name)
57
+ if not module_name:
58
+ raise AttributeError(name)
59
+ value = getattr(import_module(module_name), name)
60
+ globals()[name] = value
61
+ return value
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Elena Viter
3
+
4
+ """Request-scoped per-agent account binding for connected-account resolution.
5
+
6
+ The broker (`delegated_to_kdcube.broker.ensure_claim`) decides which connected
7
+ account satisfies a provider claim. The calling AGENT may be bound, per
8
+ provider, to specific account(s) AND — per account — to specific claims
9
+ (`account_scope: {provider_id: {account_id: [claims]}}` on its grant card). That
10
+ binding must reach the broker, but the shared resolver
11
+ (`integrations.connected_accounts.resolve_connected_account_claim`) runs
12
+ downstream of the door through a generic transport with no HTTP request.
13
+
14
+ Whoever HAS the agent's credential sets the binding into this contextvar at the
15
+ boundary — the door bridge from `request.state.delegated_credential`; a native
16
+ agent gate from the same view — and the resolver reads back the per-account
17
+ claim map for the provider it is resolving.
18
+
19
+ Default-closed for delegated callers: when an agent identity is bound for the
20
+ turn and the provider has NO binding entry, the resolver receives an EMPTY
21
+ mapping — "agent turn, nothing granted on this provider" — and the broker
22
+ denies with the agent-grant reason until the user binds an account. Only
23
+ non-agent turns (no delegated identity — the user's own trusted tools) resolve
24
+ to None, meaning no restriction. An agent never inherits the accounts the user
25
+ connected; the binding is the grant.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import contextvars
31
+ from contextlib import contextmanager
32
+ from typing import Any, Mapping
33
+
34
+ _ACCOUNT_SCOPE: contextvars.ContextVar[dict] = contextvars.ContextVar(
35
+ "kdcube_agent_account_scope", default={}
36
+ )
37
+
38
+ # The calling agent's delegated identity (client_id + delegated-resource id) for
39
+ # the current turn. The connected-account resolver reads it to route a
40
+ # per-account claim miss to THIS agent's grant card (Delegated by KDCube) instead
41
+ # of a connect-a-provider banner. Set alongside the account scope by the gate
42
+ # that holds the agent's credential; unset / non-agent turns leave it empty.
43
+ _AGENT_IDENTITY: contextvars.ContextVar[dict] = contextvars.ContextVar(
44
+ "kdcube_agent_identity", default={}
45
+ )
46
+
47
+
48
+ def normalize_account_scope(
49
+ scope: Mapping[str, Any] | None,
50
+ ) -> dict[str, dict[str, tuple[str, ...]]]:
51
+ """Normalize exact per-provider, per-account claim authority."""
52
+ normalized: dict[str, dict[str, tuple[str, ...]]] = {}
53
+ for provider, entry in dict(scope or {}).items():
54
+ pkey = str(provider or "").strip()
55
+ if not pkey:
56
+ continue
57
+ accounts: dict[str, tuple[str, ...]] = {}
58
+ if isinstance(entry, Mapping):
59
+ for account_id, claims in entry.items():
60
+ akey = str(account_id or "").strip()
61
+ if not akey:
62
+ continue
63
+ cl = tuple(str(c).strip() for c in (claims or ()) if str(c or "").strip())
64
+ accounts[akey] = cl or ("*",)
65
+ else:
66
+ for account_id in (entry or []):
67
+ akey = str(account_id or "").strip()
68
+ if akey:
69
+ accounts[akey] = ("*",)
70
+ if accounts:
71
+ normalized[pkey] = accounts
72
+ return normalized
73
+
74
+
75
+ def set_agent_account_scope(scope: Mapping[str, Any] | None) -> None:
76
+ """Bind the current agent's per-account claim scope
77
+ ({provider_id: {account_id: [claims]}}). Accepts the legacy list form
78
+ ({provider_id: [account_ids]}) and migrates it (account -> any claim)."""
79
+ _ACCOUNT_SCOPE.set(normalize_account_scope(scope))
80
+
81
+
82
+ def clear_agent_account_scope() -> None:
83
+ _ACCOUNT_SCOPE.set({})
84
+ _AGENT_IDENTITY.set({})
85
+
86
+
87
+ def set_agent_identity(*, client_id: str = "", resource: str = "") -> None:
88
+ """Bind the current agent's delegated identity (its
89
+ ``kdcube-agent:<app>:<agent>`` or ``dcr-…`` client id and the
90
+ delegated-resource id its grant is keyed under). The client id alone is
91
+ enough to mark the turn as a delegated (agent) turn — and with it the
92
+ default-closed account binding; the resource additionally lets a claim
93
+ miss deep-link the exact grant card. An empty client id clears the field."""
94
+ cid = str(client_id or "").strip()
95
+ res = str(resource or "").strip()
96
+ _AGENT_IDENTITY.set({"client_id": cid, "resource": res} if cid else {})
97
+
98
+
99
+ @contextmanager
100
+ def bind_agent_account_scope(
101
+ scope: Mapping[str, Any] | None,
102
+ *,
103
+ client_id: str,
104
+ resource: str = "",
105
+ ):
106
+ """Bind one delegated provider invocation and restore prior task state."""
107
+
108
+ cid = str(client_id or "").strip()
109
+ res = str(resource or "").strip()
110
+ scope_token = _ACCOUNT_SCOPE.set(normalize_account_scope(scope))
111
+ identity_token = _AGENT_IDENTITY.set(
112
+ {"client_id": cid, "resource": res} if cid else {}
113
+ )
114
+ try:
115
+ yield
116
+ finally:
117
+ _AGENT_IDENTITY.reset(identity_token)
118
+ _ACCOUNT_SCOPE.reset(scope_token)
119
+
120
+
121
+ def agent_identity() -> dict:
122
+ """The current agent's delegated identity ``{client_id, resource}``, or an
123
+ empty dict on a non-agent turn / when it was not set."""
124
+ return dict(_AGENT_IDENTITY.get() or {})
125
+
126
+
127
+ def account_claim_scope_for(provider_id: str) -> dict[str, tuple[str, ...]] | None:
128
+ """The current agent's per-account claim binding for ``provider_id`` —
129
+ ``{account_id: (claims...)}`` (account "*" = any account, claim "*" = any
130
+ claim).
131
+
132
+ Returns None ONLY on a non-agent turn (no delegated identity bound): the
133
+ user's own trusted tools are unrestricted. On an agent turn with no binding
134
+ entry for this provider it returns an EMPTY mapping — default-closed: the
135
+ agent is bound to no account there, and the broker denies with the
136
+ agent-grant reason until the user grants one."""
137
+ entry = _ACCOUNT_SCOPE.get().get(str(provider_id or "").strip())
138
+ if not entry:
139
+ return {} if _AGENT_IDENTITY.get() else None
140
+ return {
141
+ str(account_id).strip(): tuple(claims)
142
+ for account_id, claims in dict(entry).items()
143
+ if str(account_id or "").strip()
144
+ }
145
+
146
+
147
+ __all__ = [
148
+ "set_agent_account_scope",
149
+ "clear_agent_account_scope",
150
+ "account_claim_scope_for",
151
+ "set_agent_identity",
152
+ "agent_identity",
153
+ "bind_agent_account_scope",
154
+ "normalize_account_scope",
155
+ ]
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Elena Viter
3
+
4
+ """Host-neutral request-authenticator contracts."""
5
+
6
+ from prokura.authenticators.authority import (
7
+ AuthRequestHints,
8
+ AuthorityIdentity,
9
+ SurfaceGuardRequirement,
10
+ select_authenticator_candidates,
11
+ )
12
+ from prokura.authenticators.models import (
13
+ AuthenticatedRequest,
14
+ AuthenticatorRegistration,
15
+ RequestEnvelope,
16
+ )
17
+ from prokura.authenticators.client import (
18
+ DEFAULT_CONNECTION_HUB_BUNDLE_ID,
19
+ REQUEST_AUTHENTICATE_OPERATION,
20
+ ConnectionHubAuthenticatorsClient,
21
+ )
22
+
23
+ __all__ = [
24
+ "AuthRequestHints",
25
+ "AuthenticatedRequest",
26
+ "AuthenticatorRegistration",
27
+ "AuthorityIdentity",
28
+ "ConnectionHubAuthenticatorsClient",
29
+ "DEFAULT_CONNECTION_HUB_BUNDLE_ID",
30
+ "REQUEST_AUTHENTICATE_OPERATION",
31
+ "RequestEnvelope",
32
+ "SurfaceGuardRequirement",
33
+ "select_authenticator_candidates",
34
+ ]
@@ -0,0 +1,265 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Elena Viter
3
+
4
+ """Authority-provider primitives for Connection Hub request auth.
5
+
6
+ The selector chooses authenticators. Authenticators return identities under an
7
+ authority. Surface guards then resolve grants under the required authority.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Iterable, Mapping, Sequence, TypeVar
14
+
15
+ from prokura.authenticators.models import (
16
+ AuthenticatorRegistration,
17
+ RequestEnvelope,
18
+ )
19
+
20
+
21
+ HEADER_AUTHORITY_ID = "x-kdcube-auth-authority-id"
22
+ HEADER_AUTHENTICATOR_ID = "x-kdcube-auth-authenticator-id"
23
+ HEADER_PROVIDER = "x-kdcube-auth-provider"
24
+
25
+
26
+ def _str(value: Any) -> str:
27
+ return str(value or "").strip()
28
+
29
+
30
+ def _str_list(value: Any) -> tuple[str, ...]:
31
+ if value is None:
32
+ return ()
33
+ if isinstance(value, str):
34
+ return tuple(part.strip() for part in value.replace(",", " ").split() if part.strip())
35
+ if isinstance(value, (list, tuple, set)):
36
+ return tuple(str(part).strip() for part in value if str(part).strip())
37
+ return ()
38
+
39
+
40
+ def _field(value: Any, name: str, default: Any = "") -> Any:
41
+ if isinstance(value, Mapping):
42
+ return value.get(name, default)
43
+ return getattr(value, name, default)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class AuthRequestHints:
48
+ """Non-trusted selector hints extracted from a request envelope.
49
+
50
+ Hints narrow authenticator candidates. They do not prove authority or
51
+ identity; only the selected authenticator can do that.
52
+ """
53
+
54
+ authority_id: str = ""
55
+ authenticator_id: str = ""
56
+ provider: str = ""
57
+
58
+ @classmethod
59
+ def from_envelope(cls, envelope: RequestEnvelope | Mapping[str, Any]) -> "AuthRequestHints":
60
+ env = RequestEnvelope.coerce(envelope)
61
+ headers = env.headers
62
+ query = env.query
63
+ body = env.json_body()
64
+ return cls(
65
+ authority_id=_str(
66
+ headers.get(HEADER_AUTHORITY_ID)
67
+ or headers.get("x-kdcube-auth-authority")
68
+ or query.get("auth_authority_id")
69
+ or query.get("kdcube_auth_authority_id")
70
+ or query.get("authority_id")
71
+ or query.get("authority")
72
+ or (body.get("auth_authority_id") if isinstance(body, Mapping) else "")
73
+ or (body.get("authorityId") if isinstance(body, Mapping) else "")
74
+ ),
75
+ authenticator_id=_str(
76
+ headers.get(HEADER_AUTHENTICATOR_ID)
77
+ or query.get("auth_authenticator_id")
78
+ or query.get("kdcube_auth_authenticator_id")
79
+ or query.get("authenticator_id")
80
+ or query.get("authenticator")
81
+ or (body.get("auth_authenticator_id") if isinstance(body, Mapping) else "")
82
+ or (body.get("authenticatorId") if isinstance(body, Mapping) else "")
83
+ ),
84
+ provider=_str(
85
+ headers.get(HEADER_PROVIDER)
86
+ or headers.get("x-kdcube-auth-provider-id")
87
+ or query.get("auth_provider")
88
+ or query.get("kdcube_auth_provider")
89
+ or query.get("provider")
90
+ or (body.get("auth_provider") if isinstance(body, Mapping) else "")
91
+ or (body.get("authProvider") if isinstance(body, Mapping) else "")
92
+ ),
93
+ )
94
+
95
+ @classmethod
96
+ def coerce(cls, value: Any) -> "AuthRequestHints":
97
+ if isinstance(value, cls):
98
+ return value
99
+ if isinstance(value, RequestEnvelope):
100
+ return cls.from_envelope(value)
101
+ if isinstance(value, Mapping):
102
+ if "headers" in value or "query" in value:
103
+ return cls.from_envelope(value)
104
+ return cls(
105
+ authority_id=_str(value.get("authority_id") or value.get("authority")),
106
+ authenticator_id=_str(value.get("authenticator_id") or value.get("authenticator")),
107
+ provider=_str(value.get("provider")),
108
+ )
109
+ return cls()
110
+
111
+ def to_dict(self) -> dict[str, str]:
112
+ return {
113
+ "authority_id": self.authority_id,
114
+ "authenticator_id": self.authenticator_id,
115
+ "provider": self.provider,
116
+ }
117
+
118
+ @property
119
+ def has_explicit_selector(self) -> bool:
120
+ return bool(self.authenticator_id or self.authority_id)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class AuthorityIdentity:
125
+ """Identity verified under one authority provider."""
126
+
127
+ authority_id: str
128
+ subject: str
129
+ ref: str = ""
130
+ label: str = ""
131
+ attrs: dict[str, Any] = field(default_factory=dict)
132
+
133
+ @property
134
+ def canonical_ref(self) -> str:
135
+ if self.ref:
136
+ return self.ref
137
+ authority = _str(self.authority_id)
138
+ subject = _str(self.subject)
139
+ return f"{authority}:{subject}" if authority and subject else subject
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ return {
143
+ "authority_id": self.authority_id,
144
+ "subject": self.subject,
145
+ "ref": self.canonical_ref,
146
+ "label": self.label,
147
+ "attrs": dict(self.attrs),
148
+ }
149
+
150
+ @classmethod
151
+ def from_dict(cls, value: Mapping[str, Any] | None) -> "AuthorityIdentity":
152
+ data = dict(value or {})
153
+ return cls(
154
+ authority_id=_str(data.get("authority_id") or data.get("authority")),
155
+ subject=_str(data.get("subject") or data.get("provider_subject") or data.get("sub")),
156
+ ref=_str(data.get("ref") or data.get("identity_ref")),
157
+ label=_str(data.get("label") or data.get("name")),
158
+ attrs=dict(data.get("attrs") or {}) if isinstance(data.get("attrs"), Mapping) else {},
159
+ )
160
+
161
+ @classmethod
162
+ def coerce(cls, value: Any) -> "AuthorityIdentity":
163
+ if isinstance(value, cls):
164
+ return value
165
+ return cls.from_dict(value if isinstance(value, Mapping) else {})
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class SurfaceGuardRequirement:
170
+ """Authority/grant requirement declared by a protected surface."""
171
+
172
+ required_authority: str = "kdcube.platform"
173
+ required_grants: tuple[str, ...] = ()
174
+ accepted_authorities: tuple[str, ...] = ()
175
+ accepted_authenticators: tuple[str, ...] = ()
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ return {
179
+ "required_authority": self.required_authority,
180
+ "required_grants": list(self.required_grants),
181
+ "accepted_authorities": list(self.accepted_authorities),
182
+ "accepted_authenticators": list(self.accepted_authenticators),
183
+ }
184
+
185
+ @classmethod
186
+ def from_dict(cls, value: Mapping[str, Any] | None) -> "SurfaceGuardRequirement":
187
+ data = dict(value or {})
188
+ return cls(
189
+ required_authority=_str(data.get("required_authority") or data.get("authority")) or "kdcube.platform",
190
+ required_grants=_str_list(data.get("required_grants") or data.get("grants")),
191
+ accepted_authorities=_str_list(data.get("accepted_authorities") or data.get("authority_ids")),
192
+ accepted_authenticators=_str_list(data.get("accepted_authenticators") or data.get("authenticator_ids")),
193
+ )
194
+
195
+ @classmethod
196
+ def coerce(cls, value: Any) -> "SurfaceGuardRequirement":
197
+ if isinstance(value, cls):
198
+ return value
199
+ return cls.from_dict(value if isinstance(value, Mapping) else {})
200
+
201
+
202
+ T = TypeVar("T")
203
+
204
+
205
+ def select_authenticator_candidates(
206
+ registrations: Iterable[T],
207
+ hints: AuthRequestHints | RequestEnvelope | Mapping[str, Any] | None = None,
208
+ *,
209
+ surface: SurfaceGuardRequirement | Mapping[str, Any] | None = None,
210
+ ) -> list[T]:
211
+ """Select enabled authenticator candidates.
212
+
213
+ Selection order is intentionally deterministic. Hints are narrowing inputs,
214
+ not trusted authorization facts.
215
+ """
216
+
217
+ request_hints = AuthRequestHints.coerce(hints or {})
218
+ requirement = SurfaceGuardRequirement.coerce(surface or {})
219
+ enabled: list[T] = [row for row in registrations if bool(_field(row, "enabled", True))]
220
+
221
+ if requirement.accepted_authenticators:
222
+ accepted = set(requirement.accepted_authenticators)
223
+ enabled = [row for row in enabled if _str(_field(row, "authenticator_id")) in accepted]
224
+
225
+ if requirement.accepted_authorities:
226
+ accepted_authorities = set(requirement.accepted_authorities)
227
+ enabled = [row for row in enabled if _str(_field(row, "authority_id")) in accepted_authorities]
228
+
229
+ if request_hints.authenticator_id:
230
+ return [
231
+ row
232
+ for row in enabled
233
+ if _str(_field(row, "authenticator_id")) == request_hints.authenticator_id
234
+ ]
235
+
236
+ narrowed = enabled
237
+ if request_hints.authority_id:
238
+ narrowed = [
239
+ row
240
+ for row in narrowed
241
+ if _str(_field(row, "authority_id")) == request_hints.authority_id
242
+ ]
243
+
244
+ if request_hints.authority_id:
245
+ return narrowed
246
+
247
+ if request_hints.provider:
248
+ return [
249
+ row
250
+ for row in narrowed
251
+ if _str(_field(row, "provider")) == request_hints.provider
252
+ ]
253
+
254
+ return narrowed
255
+
256
+
257
+ __all__ = [
258
+ "AuthRequestHints",
259
+ "AuthorityIdentity",
260
+ "HEADER_AUTHENTICATOR_ID",
261
+ "HEADER_AUTHORITY_ID",
262
+ "HEADER_PROVIDER",
263
+ "SurfaceGuardRequirement",
264
+ "select_authenticator_candidates",
265
+ ]